From 10cd0e9a32f740b6e7c941c5804b1b643f62bd13 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Thu, 1 Oct 2026 17:10:07 +0530 Subject: [PATCH 1/9] Add backend support for API docs --- platform-api/api/generated.go | 167 +++++++ platform-api/internal/constants/constants.go | 26 + platform-api/internal/dto/api_document.go | 8 +- platform-api/internal/handler/api.go | 8 +- platform-api/internal/handler/api_document.go | 452 ++++++++++++++++++ platform-api/internal/model/api_document.go | 26 +- .../internal/repository/api_document.go | 165 ++++++- .../internal/repository/interfaces.go | 4 +- .../server/scope_route_coverage_test.go | 1 + platform-api/internal/server/server.go | 4 +- platform-api/internal/service/api_document.go | 236 ++++++++- platform-api/resources/openapi.yaml | 334 +++++++++++++ 12 files changed, 1373 insertions(+), 58 deletions(-) create mode 100644 platform-api/internal/handler/api_document.go diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index f67e7eccf7..ac5ce14f11 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -121,6 +121,33 @@ func (e A2ATransportProtocolBinding) Valid() bool { } } +// Defines values for APIDocumentType. +const ( + HOWTO APIDocumentType = "HOW_TO" + OTHER APIDocumentType = "OTHER" + PUBLICFORUM APIDocumentType = "PUBLIC_FORUM" + SAMPLESDK APIDocumentType = "SAMPLE_SDK" + SUPPORTFORUM APIDocumentType = "SUPPORT_FORUM" +) + +// Valid indicates whether the value is a known member of the APIDocumentType enum. +func (e APIDocumentType) Valid() bool { + switch e { + case HOWTO: + return true + case OTHER: + return true + case PUBLICFORUM: + return true + case SAMPLESDK: + return true + case SUPPORTFORUM: + return true + default: + return false + } +} + // Defines values for APIKeyItemStatus. const ( APIKeyItemStatusActive APIKeyItemStatus = "active" @@ -2013,6 +2040,119 @@ type A2ATransport struct { // A2ATransportProtocolBinding A2A protocol binding served on this transport. type A2ATransportProtocolBinding string +// APIDocument Full document response — metadata plus the UTF-8 content string. +// Content is a `string` because only text/markdown is currently accepted; +// extending to PDF/DOCX later would require either base64-encoding this +// field or splitting content into a `/content` subroute. +type APIDocument struct { + // Content The document body as a UTF-8 string. + Content string `json:"content" yaml:"content"` + + // 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 docuement. + 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 User-authored document type. DEFINITION/THUMBNAIL are reserved and + // are managed via separate dedicated endpoints. + Type APIDocumentType `json:"type" yaml:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy User who updated the docuement. + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` +} + +// APIDocumentCreateRequest Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and +// `displayName` are required; exactly one of `file` or `inlineContent` +// must carry the body. `handle` is optional — the server generates one +// from `displayName` when omitted. +type APIDocumentCreateRequest struct { + DisplayName string `json:"displayName" yaml:"displayName"` + + // File Uploaded document bytes. Mutually exclusive with `inlineContent`. + File *openapi_types.File `json:"file,omitempty" yaml:"file,omitempty"` + + // FileName Optional file name to associate with `inlineContent`. Ignored when `file` is present (the uploaded file's name is used instead). + FileName *string `json:"fileName,omitempty" yaml:"fileName,omitempty"` + + // Handle Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. + Handle *string `json:"handle,omitempty" yaml:"handle,omitempty"` + + // InlineContent Inline UTF-8 content (markdown). Mutually exclusive with `file`. + InlineContent *string `json:"inlineContent,omitempty" yaml:"inlineContent,omitempty"` + + // Type User-authored document type. DEFINITION/THUMBNAIL are reserved and + // are managed via separate dedicated endpoints. + Type APIDocumentType `json:"type" yaml:"type"` +} + +// 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 docuement. + 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 User-authored document type. DEFINITION/THUMBNAIL are reserved and + // are managed via separate dedicated endpoints. + Type APIDocumentType `json:"type" yaml:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy User who updated the docuement. + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` +} + +// APIDocumentType User-authored document type. DEFINITION/THUMBNAIL are reserved and +// are managed via separate dedicated endpoints. +type APIDocumentType string + +// APIDocumentUpdateRequest Multipart form for `PUT /apis/{apiType}/{apiId}/docs/{docId}`. Every +// field is optional; omitted fields leave the stored value unchanged. +// Supplying neither `file` nor `inlineContent` means a metadata-only +// update — the stored bytes are not touched. +type APIDocumentUpdateRequest struct { + DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` + + // File Replacement document bytes. Mutually exclusive with `inlineContent`. + File *openapi_types.File `json:"file,omitempty" yaml:"file,omitempty"` + + // FileName Optional file name update. Applied alongside a new upload. + FileName *string `json:"fileName,omitempty" yaml:"fileName,omitempty"` + + // InlineContent Replacement UTF-8 content. Mutually exclusive with `file`. + InlineContent *string `json:"inlineContent,omitempty" yaml:"inlineContent,omitempty"` + + // Type User-authored document type. DEFINITION/THUMBNAIL are reserved and + // are managed via separate dedicated endpoints. + Type *APIDocumentType `json:"type,omitempty" yaml:"type,omitempty"` +} + // APIKeyItem defines model for APIKeyItem. type APIKeyItem struct { // AllowedTargets Comma-separated list of allowed gateways; 'ALL' means unrestricted @@ -4984,6 +5124,13 @@ type DeploymentId = openapi_types.UUID // DeploymentStatusQ defines model for deploymentStatus-Q. type DeploymentStatusQ string +// DocId defines model for docId. +type DocId = string + +// DocTypeQ User-authored document type. DEFINITION/THUMBNAIL are reserved and +// are managed via separate dedicated endpoints. +type DocTypeQ = APIDocumentType + // EntityIDQ defines model for entityID-Q. type EntityIDQ = string @@ -5231,6 +5378,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 +5929,12 @@ 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 = APIDocumentCreateRequest + +// UpdateAPIDocumentMultipartRequestBody defines body for UpdateAPIDocument for multipart/form-data ContentType. +type UpdateAPIDocumentMultipartRequestBody = APIDocumentUpdateRequest + // CreateApplicationJSONRequestBody defines body for CreateApplication for application/json ContentType. type CreateApplicationJSONRequestBody = CreateApplicationRequest diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 702d12b872..e08f1ed581 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -313,6 +313,32 @@ const ( DocumentDisplayNameDefinition = "OpenAPI Definition" ) +const ( + DocumentTypeThumbnail = "THUMBNAIL" + DocumentHandleThumbnail = "api-thumbnail" +) + +const ( + DocumentTypeHowTo = "HOW_TO" + DocumentTypeSampleAndSdk = "SAMPLE_SDK" + DocumentTypeSupportForum = "SUPPORT_FORUM" + DocumentTypePublicForum = "PUBLIC_FORUM" + DocumentTypeOther = "OTHER" +) + +var ValidAPIDocumentUserTypes = map[string]bool{ + DocumentTypeHowTo: true, + DocumentTypeSampleAndSdk: true, + DocumentTypeSupportForum: true, + DocumentTypePublicForum: true, + DocumentTypeOther: true, +} + +var ReservedAPIDocumentTypes = []string{ + DocumentTypeDefinition, + DocumentTypeThumbnail, +} + // 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..5c31f12a85 100644 --- a/platform-api/internal/dto/api_document.go +++ b/platform-api/internal/dto/api_document.go @@ -37,8 +37,10 @@ type PutAPIDocumentRequest struct { Content []byte } -// APIDocumentContent is returned by GetDocument — the raw spec bytes ready to serve. -type APIDocumentContent struct { +type UpdateAPIDocumentRequest struct { + Type *string + DisplayName *string + FileName *string Content []byte - ContentType string + ContentType *string } diff --git a/platform-api/internal/handler/api.go b/platform-api/internal/handler/api.go index f56bdc483f..78ff8fad00 100644 --- a/platform-api/internal/handler/api.go +++ b/platform-api/internal/handler/api.go @@ -431,8 +431,10 @@ 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. + doc, err := h.apiDocumentService.GetDocument(artifactUUID, constants.DocumentHandleDefinition, orgId, + constants.DocumentTypeDefinition) if err != nil { return serviceError(err, "failed to fetch openapi spec for API "+restApiId) } @@ -518,7 +520,7 @@ func (h *APIHandler) PutOpenAPISpec(w http.ResponseWriter, r *http.Request) erro 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..21c7b93ac1 --- /dev/null +++ b/platform-api/internal/handler/api_document.go @@ -0,0 +1,452 @@ +/* + * 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" + "time" + + "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/model" + "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 five 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("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 + } + docType := strings.TrimSpace(r.URL.Query().Get("type")) + if docType != "" { + docType = strings.ToUpper(docType) + } + 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") + } + + items := make([]api.APIDocumentMetadata, 0, len(docs)) + for _, d := range docs { + items = append(items, documentToAPIMetadata(d)) + } + resp := api.APIDocumentListResponse{ + Count: len(items), + List: items, + 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 metadata + the UTF-8 content string in a single JSON response. +// Content is a string because today the endpoint only accepts markdown; +// extending to binary formats later requires either base64-encoding this +// field or splitting content into its own subroute. +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") + } + + doc, err := h.service.GetDocument(artifactUUID, docID, orgID, "") + if err != nil { + return serviceError(err, "failed to get document") + } + + httputil.WriteJSON(w, http.StatusOK, documentToAPIDocument(doc)) + return nil +} + +// CreateDocument handles POST /apis/{apiType}/{apiId}/docs. Expects a +// multipart/form-data body with: type (required), displayName (required), +// handle (optional), 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 + } + + req := &dto.CreateAPIDocumentRequest{ + Type: strings.ToUpper(parsed.docType), + Handle: parsed.handle, + DisplayName: parsed.displayName, + FileName: parsed.fileName, + Content: parsed.content, + } + + 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+"/"+handle) + httputil.WriteJSON(w, http.StatusCreated, documentToAPIMetadata(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 + } + + req := &dto.UpdateAPIDocumentRequest{} + if parsed.docTypeSet { + upper := strings.ToUpper(parsed.docType) + req.Type = &upper + } + if parsed.displayNameSet { + req.DisplayName = &parsed.displayName + } + if parsed.content != nil { + req.Content = parsed.content + if parsed.contentTypeSet { + req.ContentType = &parsed.contentType + } + // A new upload may bring a new filename too; reflect it when set. + if parsed.fileNameSet { + req.FileName = &parsed.fileName + } else if parsed.fileName != "" { + 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, documentToAPIMetadata(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 + handle string + 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["handle"]; ok && len(vals) > 0 { + parsed.handle = strings.TrimSpace(vals[0]) + } + if vals, ok := form.Value["displayName"]; ok { + parsed.displayNameSet = true + if len(vals) > 0 { + parsed.displayName = strings.TrimSpace(vals[0]) + } + } + } + + // `file` wins over `inlineContent` when both are supplied — but we reject + // outright rather than silently pick, matching the openapi import pattern. + file, header, fileErr := r.FormFile("file") + hasFile := fileErr == nil + 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 hasFile && hasInline { + file.Close() + return parsedDocForm{}, apperror.ValidationFailed.New("provide either `file` or `inlineContent`, not both") + } + if requireContent && !hasFile && !hasInline { + return parsedDocForm{}, apperror.ValidationFailed.New("one of `file` or `inlineContent` is required") + } + + if hasFile { + defer file.Close() + data, readErr := io.ReadAll(io.LimitReader(file, h.maxBodyBytes+1)) + if readErr != nil { + return parsedDocForm{}, apperror.ValidationFailed.New("failed to read uploaded file") + } + if int64(len(data)) > h.maxBodyBytes { + return parsedDocForm{}, apperror.PayloadTooLarge.New("file exceeds maximum allowed size") + } + parsed.content = data + parsed.fileName = sanitizeUploadFileName(header.Filename) + parsed.fileNameSet = parsed.fileName != "" + // Leave parsed.contentType unset — the service's DetectContentType + // does the sniff from bytes + filename, in one place. + } else if hasInline { + parsed.content = []byte(inlineContent) + // Inline content has no uploaded filename; the caller may supply one + // alongside inlineContent to keep an existing 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])) + } + } + } + // Inline content is markdown by convention. The explicit default + // survives even when no filename hint is supplied, so a plain + // inlineContent create still gets stored as markdown. + parsed.contentType = "text/markdown; charset=utf-8" + parsed.contentTypeSet = true + } + + return parsed, nil +} + +// documentToAPIMetadata strips the content BLOB and reshapes a model.Document +// into the generated api.APIDocumentMetadata response type. Empty optional +// fields on the model become nil pointers so JSON output omits them, matching +// the OpenAPI contract's `omitempty` optionality. +// +// Note: api.APIDocumentMetadata.Id in the spec is the document handle, not +// the DB UUID — the handle is what callers use in /docs/{docId}. +func documentToAPIMetadata(d *model.Document) api.APIDocumentMetadata { + return api.APIDocumentMetadata{ + Id: d.Handle, + Type: api.APIDocumentType(d.Type), + DisplayName: d.DisplayName, + FileName: optionalString(d.FileName), + ContentType: optionalString(d.ContentType), + CreatedBy: optionalString(d.CreatedBy), + CreatedAt: optionalTime(d.CreatedAt), + UpdatedBy: optionalString(d.UpdatedBy), + UpdatedAt: optionalTime(d.UpdatedAt), + } +} + +// documentToAPIDocument is documentToAPIMetadata plus the UTF-8 content +// payload — the response shape for the single-doc GET. +func documentToAPIDocument(d *model.Document) api.APIDocument { + return api.APIDocument{ + Id: d.Handle, + Type: api.APIDocumentType(d.Type), + DisplayName: d.DisplayName, + FileName: optionalString(d.FileName), + ContentType: optionalString(d.ContentType), + CreatedBy: optionalString(d.CreatedBy), + CreatedAt: optionalTime(d.CreatedAt), + UpdatedBy: optionalString(d.UpdatedBy), + UpdatedAt: optionalTime(d.UpdatedAt), + Content: string(d.Content), + } +} + +// optionalString returns nil for an empty string so JSON serialisation +// honours `omitempty` on *string fields in the generated api types. +func optionalString(s string) *string { + if s == "" { + return nil + } + return &s +} + +// optionalTime returns nil for a zero time.Time so JSON serialisation honours +// `omitempty` on *time.Time fields in the generated api types. +func optionalTime(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} 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..b76bddfb2a 100644 --- a/platform-api/internal/repository/api_document.go +++ b/platform-api/internal/repository/api_document.go @@ -25,6 +25,7 @@ import ( "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,26 +63,44 @@ 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 } @@ -92,7 +111,8 @@ func (r *DocumentRepo) GetDocumentByArtifactAndType(artifactUUID, docType, orgUU 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 type = ? AND organization_uuid = ? `) @@ -101,7 +121,8 @@ func (r *DocumentRepo) GetDocumentByArtifactAndType(artifactUUID, docType, orgUU 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 @@ -111,17 +132,85 @@ func (r *DocumentRepo) GetDocumentByArtifactAndType(artifactUUID, docType, orgUU return doc, nil } -// UpsertDocument inserts or updates a document for the given (artifact_uuid, handle) pair. +// 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 != "" { + whereClause += ` AND type = ?` + args = append(args, docType) + } + 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, ''), + COALESCE(created_by, ''), created_at, + COALESCE(updated_by, ''), updated_at + FROM api_documents + ` + 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) + } + docs = append(docs, doc) + } + 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 scoped by (artifact_uuid, handle, type) 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) @@ -140,7 +229,7 @@ func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { // A concurrent writer inserted between our UPDATE and INSERT; retry the UPDATE. _, 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 (retry update): %w", err) @@ -152,6 +241,54 @@ func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { return nil } +// UpdateDocument updates an existing document identified by artifact UUID + handle + org. +// doc.Content is written only when updateContent is true, so a metadata-only PUT (no new file/inlineContent) +// never overwrites the stored bytes with an empty payload. +func (r *DocumentRepo) UpdateDocument(doc *model.Document, updateContent bool) error { + now := time.Now().UTC() + 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 = ? + `) + result, err = r.db.Exec(query, + doc.Type, doc.DisplayName, doc.FileName, doc.ContentType, doc.Content, + doc.UpdatedBy, now, + doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + ) + } 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 = ? + `) + result, err = r.db.Exec(query, + doc.Type, doc.DisplayName, doc.FileName, + doc.UpdatedBy, now, + doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + ) + } + 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 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 = ?`) diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 0cb36f9e31..7de1f852d4 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -517,9 +517,11 @@ 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) + GetDocument(artifactUUID, handle, orgUUID, docType string) (*model.Document, error) GetDocumentByArtifactAndType(artifactUUID, docType, orgUUID string) (*model.Document, error) + ListDocumentsByArtifact(artifactUUID, orgUUID, docType string, limit, offset int) ([]*model.Document, int, error) UpsertDocument(doc *model.Document) error + UpdateDocument(doc *model.Document, updateContent bool) error DeleteDocument(artifactUUID, handle, orgUUID string) error DocumentHandleExistsForArtifact(artifactUUID, handle string) (bool, error) // GetDocumentUUIDsByHandles resolves each handle to its document uuid, 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..2084a5b5d5 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,7 @@ 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) gatewayHandler := handler.NewGatewayHandler(gatewayService, identityService, slogger) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService, subscriptionPlanService, identityService, slogger) subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, identityService, slogger) @@ -480,6 +481,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, appHandler.RegisterRoutes(core) apiPortalHandler.RegisterRoutes(core) apiHandler.RegisterRoutes(core) + apiDocumentHandler.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..e4bbe64350 100644 --- a/platform-api/internal/service/api_document.go +++ b/platform-api/internal/service/api_document.go @@ -18,7 +18,10 @@ package service import ( + "errors" + "fmt" "log/slog" + "net/http" "path/filepath" "strings" @@ -36,20 +39,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,7 +93,7 @@ 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, } @@ -96,32 +124,40 @@ func (s *APIDocumentService) CreateDocument(req *dto.CreateAPIDocumentRequest, o 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) { +// CreateApiDocument creates a user-authored document attached to an artifact. +// Validates the caller-supplied type against ValidAPIDocumentUserTypes +// (so reserved types cannot be reached through this path entry) +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 nil, apperror.ValidationFailed.New("artifact UUID is required") + return "", apperror.ValidationFailed.New("artifact UUID is required") } - - 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 + if !constants.ValidAPIDocumentUserTypes[req.Type] { + return "", apperror.ValidationFailed.New("invalid document type") } - - if doc == nil { - return nil, apperror.NotFound.New() + if strings.TrimSpace(req.DisplayName) == "" { + return "", apperror.ValidationFailed.New("displayName is required") + } + if req.Handle != "" { + 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.PutAPIDocumentRequest, orgId string, userId string, artifactUUID string) error { if req == nil { return apperror.ValidationFailed.New("document request is required") } @@ -136,7 +172,7 @@ 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, } @@ -183,8 +219,9 @@ func (s *APIDocumentService) PutDocument(req *dto.PutAPIDocumentRequest, orgId s return nil } -// DeleteDocument deletes a document for an artifact identified by its handle. -func (s *APIDocumentService) DeleteDocument(artifactUUID, handle, orgId string) error { +// 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") } @@ -192,10 +229,139 @@ func (s *APIDocumentService) DeleteDocument(artifactUUID, handle, orgId string) return apperror.ValidationFailed.New("document handle is required") } - if err := s.documentRepo.DeleteDocument(artifactUUID, handle, orgId); err != nil { + // docType="" excludes reserved types at the repo layer, so a DELETE of + // the DEFINITION/THUMBNAIL handle via this surface finds no row and 404s. + 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.DeleteDocument(artifactUUID, handle, orgID); err != nil { s.slogger.Error("Failed to delete document", "artifactUUID", artifactUUID, "handle", handle, "error", err) return err } + if err := s.auditRepo.Record("DELETE", artifactUUID, "api_definition", 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) ([]*model.Document, int, error) { + if artifactUUID == "" { + return nil, 0, apperror.ValidationFailed.New("artifact UUID is required") + } + // if docType is supplied, it must be a valid non reserved doc type + if docType != "" { + if !constants.ValidAPIDocumentUserTypes[docType] { + return []*model.Document{}, 0, nil + } + } + + 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 + } + return docs, total, nil +} + +// GetDocument retrieves a document (metadata + content) by handle, scoped +// to artifactUUID + orgID. docType is optional: +// +// - docType != "": strict match on type too. Pass the reserved type +// (e.g. constants.DocumentTypeDefinition) when fetching the OpenAPI spec +// or thumbnail. +// - docType == "": the request came from the user-facing /docs/{docId} +// path. The repository excludes reserved types at the SQL layer, so a +// caller cannot fetch the OpenAPI spec or thumbnail by guessing the +// handle on this endpoint. +func (s *APIDocumentService) GetDocument(artifactUUID, handle, orgID, docType string) (*model.Document, 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, docType) + 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() + } + return doc, nil +} + +// 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") + } + + // docType="" excludes reserved types at the repo layer, so a PUT against + // the DEFINITION/THUMBNAIL handle via this surface finds no row + 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() + } + + merged := *existing + merged.UpdatedBy = userID + if req.Type != nil { + if !constants.ValidAPIDocumentUserTypes[*req.Type] { + return apperror.ValidationFailed.New("invalid document type") + } + merged.Type = *req.Type + } + if req.DisplayName != nil { + trimmed := strings.TrimSpace(*req.DisplayName) + if trimmed == "" { + return apperror.ValidationFailed.New("displayName must not be empty") + } + merged.DisplayName = trimmed + } + if req.FileName != nil { + merged.FileName = *req.FileName + } + updateContent := req.Content != nil + if updateContent { + merged.Content = req.Content + if req.ContentType != nil { + merged.ContentType = *req.ContentType + } + } + + if err := s.documentRepo.UpdateDocument(&merged, 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_definition", orgID, userID); err != nil { + s.slogger.Error("Failed to record audit entry for document update", "artifactUUID", artifactUUID, "error", err) + } return nil } @@ -294,6 +460,26 @@ 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: + 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/resources/openapi.yaml b/platform-api/resources/openapi.yaml index a74576d9f0..974dd41278 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1926,6 +1926,173 @@ 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: + # TODO + - ap:rest_api:read + - ap:rest_api: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:rest_api:create + - ap:rest_api:manage + tags: + - API Documents + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/APIDocumentCreateRequest' + 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 a specifc API document + description: Returns metadata and the document's UTF-8 content. + operationId: GetAPIDocument + security: + - OAuth2Security: + - ap:rest_api:read + - ap:rest_api:manage + tags: + - API Documents + responses: + '200': + description: Document retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/APIDocument' + '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:rest_api:update + - ap:rest_api:manage + tags: + - API Documents + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/APIDocumentUpdateRequest' + 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:rest_api:delete + - ap:rest_api: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' + /llm-provider-templates: post: summary: Create a new LLM provider template family @@ -8981,6 +9148,151 @@ components: type: string description: Raw spec content + APIDocumentType: + type: string + description: | + User-authored document type. DEFINITION/THUMBNAIL are reserved and + are managed via separate dedicated endpoints. + enum: + - HOW_TO + - SAMPLE_SDK + - SUPPORT_FORUM + - PUBLIC_FORUM + # TODO: custom types + - OTHER + example: HOW_TO + + 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: + $ref: '#/components/schemas/APIDocumentType' + 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 docuement. + updatedBy: + type: string + description: User who updated the docuement. + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + APIDocument: + description: | + Full document response — metadata plus the UTF-8 content string. + Content is a `string` because only text/markdown is currently accepted; + extending to PDF/DOCX later would require either base64-encoding this + field or splitting content into a `/content` subroute. + allOf: + - $ref: '#/components/schemas/APIDocumentMetadata' + - type: object + required: + - content + properties: + content: + type: string + description: The document body as a UTF-8 string. + + 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' + + APIDocumentCreateRequest: + type: object + description: | + Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and + `displayName` are required; exactly one of `file` or `inlineContent` + must carry the body. `handle` is optional — the server generates one + from `displayName` when omitted. + required: + - type + - displayName + properties: + type: + $ref: '#/components/schemas/APIDocumentType' + displayName: + type: string + example: Payment Webhook How-To + handle: + type: string + description: Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. + example: payment-webhook-howto + file: + type: string + format: binary + description: Uploaded document bytes. Mutually exclusive with `inlineContent`. + inlineContent: + type: string + description: Inline UTF-8 content (markdown). Mutually exclusive with `file`. + fileName: + type: string + description: Optional file name to associate with `inlineContent`. Ignored when `file` is present (the uploaded file's name is used instead). + example: payment-webhook.md + + APIDocumentUpdateRequest: + type: object + description: | + Multipart form for `PUT /apis/{apiType}/{apiId}/docs/{docId}`. Every + field is optional; omitted fields leave the stored value unchanged. + Supplying neither `file` nor `inlineContent` means a metadata-only + update — the stored bytes are not touched. + properties: + type: + $ref: '#/components/schemas/APIDocumentType' + displayName: + type: string + example: Payment Webhook How-To (v2) + file: + type: string + format: binary + description: Replacement document bytes. Mutually exclusive with `inlineContent`. + inlineContent: + type: string + description: Replacement UTF-8 content. Mutually exclusive with `file`. + fileName: + type: string + description: Optional file name update. Applied alongside a new upload. + example: payment-webhook-v2.md + TimeUnit: type: string description: Time unit for API key expiration duration @@ -13162,6 +13474,26 @@ 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: + $ref: '#/components/schemas/APIDocumentType' + apiType-Q: name: apiType in: query @@ -13290,6 +13622,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 From 7d8a0dc6d0e1b68d547f92fd8bfc7e4f4a4585a3 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Fri, 2 Oct 2026 01:11:36 +0530 Subject: [PATCH 2/9] UI support for adding API Docs --- platform-api/api/generated.go | 31 - platform-api/internal/handler/api_document.go | 65 +- platform-api/resources/openapi.yaml | 66 +- .../src/api/generated/operationScopes.ts | 6 + .../src/api/generated/platform.d.ts | 414 ++++++++++++ .../apiDocuments.endpoints.test.ts | 196 ++++++ .../apiDocuments/apiDocuments.endpoints.ts | 157 +++++ .../apiDocuments/apiDocuments.hooks.ts | 188 ++++++ .../apiDocuments/apiDocuments.queries.ts | 105 +++ .../src/api/resources/apiDocuments/index.ts | 50 ++ .../MarkdownView/MarkdownView.test.tsx | 50 ++ .../components/MarkdownView/MarkdownView.tsx | 174 +++++ .../src/components/MarkdownView/index.ts | 19 + .../components/MarkdownView/markdown.test.ts | 109 +++ .../src/components/MarkdownView/markdown.ts | 288 ++++++++ .../src/i18n/messages/en.json | 284 ++++++-- .../apis/overview/DocumentsPanel.test.tsx | 87 +++ .../apis/overview/DocumentsPanel.tsx | 195 ++++-- .../apis/overview/OverviewTab.tsx | 5 +- .../apis/overview/mockDocuments.json | 20 - .../develop/documents/DocumentEditor.tsx | 627 ++++++++++++++++++ .../develop/documents/DocumentList.tsx | 226 +++++++ .../develop/documents/DocumentViewer.tsx | 247 +++++++ .../develop/documents/DocumentsBrowser.tsx | 170 +++++ .../develop/documents/DocumentsPanel.test.tsx | 202 ++++++ .../develop/documents/DocumentsPanel.tsx | 71 +- .../develop/documents/documentContent.ts | 30 + .../develop/documents/documentTypes.ts | 76 +++ .../develop/documents/documentsSearch.ts | 60 ++ .../develop/documents/documentsUtils.test.ts | 66 ++ .../develop/documents/markdownFile.ts | 73 ++ 31 files changed, 4149 insertions(+), 208 deletions(-) create mode 100644 portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.test.ts create mode 100644 portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.ts create mode 100644 portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts create mode 100644 portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.ts create mode 100644 portals/api-control-plane/src/api/resources/apiDocuments/index.ts create mode 100644 portals/api-control-plane/src/components/MarkdownView/MarkdownView.test.tsx create mode 100644 portals/api-control-plane/src/components/MarkdownView/MarkdownView.tsx create mode 100644 portals/api-control-plane/src/components/MarkdownView/index.ts create mode 100644 portals/api-control-plane/src/components/MarkdownView/markdown.test.ts create mode 100644 portals/api-control-plane/src/components/MarkdownView/markdown.ts create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx delete mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/mockDocuments.json create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentList.tsx create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentViewer.tsx create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsBrowser.tsx create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.test.tsx create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentContent.ts create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentTypes.ts create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsSearch.ts create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsUtils.test.ts create mode 100644 portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/markdownFile.ts diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index ac5ce14f11..898a80dbb1 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -2040,37 +2040,6 @@ type A2ATransport struct { // A2ATransportProtocolBinding A2A protocol binding served on this transport. type A2ATransportProtocolBinding string -// APIDocument Full document response — metadata plus the UTF-8 content string. -// Content is a `string` because only text/markdown is currently accepted; -// extending to PDF/DOCX later would require either base64-encoding this -// field or splitting content into a `/content` subroute. -type APIDocument struct { - // Content The document body as a UTF-8 string. - Content string `json:"content" yaml:"content"` - - // 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 docuement. - 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 User-authored document type. DEFINITION/THUMBNAIL are reserved and - // are managed via separate dedicated endpoints. - Type APIDocumentType `json:"type" yaml:"type"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` - - // UpdatedBy User who updated the docuement. - UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` -} - // APIDocumentCreateRequest Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and // `displayName` are required; exactly one of `file` or `inlineContent` // must carry the body. `handle` is optional — the server generates one diff --git a/platform-api/internal/handler/api_document.go b/platform-api/internal/handler/api_document.go index 21c7b93ac1..4c251be193 100644 --- a/platform-api/internal/handler/api_document.go +++ b/platform-api/internal/handler/api_document.go @@ -67,7 +67,7 @@ func NewAPIDocumentHandler(apiDocumentService *service.APIDocumentService, ident } } -// RegisterRoutes registers the five docs operations under a single +// 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 @@ -78,6 +78,7 @@ func (h *APIDocumentHandler) RegisterRoutes(mux router.Router) { 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)) } @@ -139,10 +140,7 @@ func (h *APIDocumentHandler) ListDocuments(w http.ResponseWriter, r *http.Reques } // GetDocument handles GET /apis/{apiType}/{apiId}/docs/{docId}. -// Returns metadata + the UTF-8 content string in a single JSON response. -// Content is a string because today the endpoint only accepts markdown; -// extending to binary formats later requires either base64-encoding this -// field or splitting content into its own subroute. +// Returns document metadata only func (h *APIDocumentHandler) GetDocument(w http.ResponseWriter, r *http.Request) error { orgID, artifactUUID, err := h.resolveArtifactUUID(r) if err != nil { @@ -158,7 +156,45 @@ func (h *APIDocumentHandler) GetDocument(w http.ResponseWriter, r *http.Request) return serviceError(err, "failed to get document") } - httputil.WriteJSON(w, http.StatusOK, documentToAPIDocument(doc)) + httputil.WriteJSON(w, http.StatusOK, documentToAPIMetadata(doc)) + return nil +} + +// GetDocumentContent handles GET /apis/{apiType}/{apiId}/docs/{docId}/content. +// Streams the stored document bytes with the document's stored Content-Type +// header so the caller can handle the body correctly for the current format +// (text/markdown) and any future format (PDF, DOCX, images) without any +// schema changes — the stored content-type drives interpretation. +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") + } + + doc, err := h.service.GetDocument(artifactUUID, docID, orgID, "") + if err != nil { + return serviceError(err, "failed to get document content") + } + + if len(doc.Content) == 0 { + w.WriteHeader(http.StatusNoContent) + return nil + } + + ct := doc.ContentType + if ct == "" { + ct = "application/octet-stream" + } + w.Header().Set("Content-Type", ct) + if doc.FileName != "" { + fn := strings.NewReplacer(`"`, `\"`, `\`, `\\`).Replace(doc.FileName) + w.Header().Set("Content-Disposition", `inline; filename="`+fn+`"`) + } + _, _ = w.Write(doc.Content) return nil } @@ -416,23 +452,6 @@ func documentToAPIMetadata(d *model.Document) api.APIDocumentMetadata { } } -// documentToAPIDocument is documentToAPIMetadata plus the UTF-8 content -// payload — the response shape for the single-doc GET. -func documentToAPIDocument(d *model.Document) api.APIDocument { - return api.APIDocument{ - Id: d.Handle, - Type: api.APIDocumentType(d.Type), - DisplayName: d.DisplayName, - FileName: optionalString(d.FileName), - ContentType: optionalString(d.ContentType), - CreatedBy: optionalString(d.CreatedBy), - CreatedAt: optionalTime(d.CreatedAt), - UpdatedBy: optionalString(d.UpdatedBy), - UpdatedAt: optionalTime(d.UpdatedAt), - Content: string(d.Content), - } -} - // optionalString returns nil for an empty string so JSON serialisation // honours `omitempty` on *string fields in the generated api types. func optionalString(s string) *string { diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 974dd41278..c6680a1c4f 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -2010,8 +2010,10 @@ paths: - $ref: '#/components/parameters/apiHandle' - $ref: '#/components/parameters/docId' get: - summary: Get a specifc API document - description: Returns metadata and the document's UTF-8 content. + summary: Get API document metadata + description: | + Returns document metadata only. Use `GET …/{docId}/content` to retrieve + the raw document bytes. operationId: GetAPIDocument security: - OAuth2Security: @@ -2021,11 +2023,11 @@ paths: - API Documents responses: '200': - description: Document retrieved successfully + description: Document metadata retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/APIDocument' + $ref: '#/components/schemas/APIDocumentMetadata' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2093,6 +2095,46 @@ paths: '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:rest_api:read + - ap:rest_api: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' + /llm-provider-templates: post: summary: Create a new LLM provider template family @@ -9202,22 +9244,6 @@ components: type: string format: date-time - APIDocument: - description: | - Full document response — metadata plus the UTF-8 content string. - Content is a `string` because only text/markdown is currently accepted; - extending to PDF/DOCX later would require either base64-encoding this - field or splitting content into a `/content` subroute. - allOf: - - $ref: '#/components/schemas/APIDocumentMetadata' - - type: object - required: - - content - properties: - content: - type: string - description: The document body as a UTF-8 string. - APIDocumentListResponse: type: object required: diff --git a/portals/api-control-plane/src/api/generated/operationScopes.ts b/portals/api-control-plane/src/api/generated/operationScopes.ts index 26cf5f90f3..28477e2120 100644 --- a/portals/api-control-plane/src/api/generated/operationScopes.ts +++ b/portals/api-control-plane/src/api/generated/operationScopes.ts @@ -415,6 +415,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:manage', 'ap:agent_proxy:manage', ], + CreateAPIDocument: ['ap:rest_api:create', 'ap:rest_api:manage'], CreateAPIKey: [ 'ap:api_key:all:manage', 'ap:rest_api:api_key:create', @@ -472,6 +473,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:manage', 'ap:agent_proxy:manage', ], + DeleteAPIDocument: ['ap:rest_api:delete', 'ap:rest_api:manage'], DeleteApiPortal: ['ap:api_portal:delete', 'ap:api_portal:manage'], DeleteApplication: ['ap:application:delete', 'ap:application:manage'], DeleteBuild: ['ap:rest_api:build:delete', 'ap:rest_api:build:manage', 'ap:rest_api:manage'], @@ -570,6 +572,8 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:read', 'ap:agent_proxy:manage', ], + GetAPIDocument: ['ap:rest_api:manage', 'ap:rest_api:read'], + GetAPIDocumentContent: ['ap:rest_api:manage', 'ap:rest_api:read'], GetApiPortal: ['ap:api_portal:manage', 'ap:api_portal:read'], getApiPublication: ['ap:api_portal:publication:read'], getApiPublicationDefinition: ['ap:api_portal:publication:read'], @@ -684,6 +688,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:read', 'ap:agent_proxy:manage', ], + ListAPIDocuments: ['ap:rest_api:manage', 'ap:rest_api:read'], ListApiPortals: ['ap:api_portal:manage', 'ap:api_portal:read'], listApiPublications: ['ap:api_publication:read'], ListApplicationAPIKeys: [ @@ -830,6 +835,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:manage', 'ap:api_key:all:manage', ], + UpdateAPIDocument: ['ap:rest_api:manage', 'ap:rest_api:update'], UpdateAPIKey: [ 'ap:api_key:all:manage', 'ap:rest_api:api_key:manage', 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..d56e60f713 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,103 @@ 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; + }; "/llm-provider-templates": { parameters: { query?: never; @@ -3903,6 +4000,102 @@ export interface components { /** @description Raw spec content */ content?: string; }; + /** + * @description User-authored document type. DEFINITION/THUMBNAIL are reserved and + * are managed via separate dedicated endpoints. + * @example HOW_TO + * @enum {string} + */ + APIDocumentType: "HOW_TO" | "SAMPLE_SDK" | "SUPPORT_FORUM" | "PUBLIC_FORUM" | "OTHER"; + /** @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; + type: components["schemas"]["APIDocumentType"]; + /** @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 docuement. */ + createdBy?: string; + /** @description User who updated the docuement. */ + 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 `POST /apis/{apiType}/{apiId}/docs`. `type` and + * `displayName` are required; exactly one of `file` or `inlineContent` + * must carry the body. `handle` is optional — the server generates one + * from `displayName` when omitted. + */ + APIDocumentCreateRequest: { + type: components["schemas"]["APIDocumentType"]; + /** @example Payment Webhook How-To */ + displayName: string; + /** + * @description Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. + * @example payment-webhook-howto + */ + handle?: string; + /** + * Format: binary + * @description Uploaded document bytes. Mutually exclusive with `inlineContent`. + */ + file?: string; + /** @description Inline UTF-8 content (markdown). Mutually exclusive with `file`. */ + inlineContent?: string; + /** + * @description Optional file name to associate with `inlineContent`. Ignored when `file` is present (the uploaded file's name is used instead). + * @example payment-webhook.md + */ + fileName?: string; + }; + /** + * @description Multipart form for `PUT /apis/{apiType}/{apiId}/docs/{docId}`. Every + * field is optional; omitted fields leave the stored value unchanged. + * Supplying neither `file` nor `inlineContent` means a metadata-only + * update — the stored bytes are not touched. + */ + APIDocumentUpdateRequest: { + type?: components["schemas"]["APIDocumentType"]; + /** @example Payment Webhook How-To (v2) */ + displayName?: string; + /** + * Format: binary + * @description Replacement document bytes. Mutually exclusive with `inlineContent`. + */ + file?: string; + /** @description Replacement UTF-8 content. Mutually exclusive with `file`. */ + inlineContent?: string; + /** + * @description Optional file name update. Applied alongside a new upload. + * @example payment-webhook-v2.md + */ + fileName?: string; + }; /** * @description Time unit for API key expiration duration * @example days @@ -6902,6 +7095,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": components["schemas"]["APIDocumentType"]; /** @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 +8672,219 @@ 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"]["APIDocumentCreateRequest"]; + }; + }; + 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"]["APIDocumentUpdateRequest"]; + }; + }; + 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"]; + }; + }; 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..ab14d45cff --- /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: 'HOW_TO', + ...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: 'HOW_TO' }); + + 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('HOW_TO'); + }); + + 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: 'HOW_TO', + }); + + 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' }); + + 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..c3d92c23ad --- /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 = Schema<'APIDocumentType'>; +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.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts new file mode 100644 index 0000000000..7467be692a --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts @@ -0,0 +1,188 @@ +/* + * 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 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, + }); + } + invalidate(apiType, apiId); + }, + }); +}; 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/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..0bcaeaf2df --- /dev/null +++ b/portals/api-control-plane/src/components/MarkdownView/markdown.ts @@ -0,0 +1,288 @@ +/* + * 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; + if (href.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); + +/** Parses a Markdown document into blocks. Never throws: unknown syntax becomes text. */ +export function parseMarkdown(source: string): 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 body: string[] = []; + i += 1; + while (i < lines.length && !lines[i].trimStart().startsWith(marker)) { + 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; + } + blocks.push({ kind: 'quote', children: parseMarkdown(body.join('\n')) }); + 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/i18n/messages/en.json b/portals/api-control-plane/src/i18n/messages/en.json index cc259d970f..0c2699e289 100644 --- a/portals/api-control-plane/src/i18n/messages/en.json +++ b/portals/api-control-plane/src/i18n/messages/en.json @@ -1097,15 +1097,25 @@ "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.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 +1433,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 +1448,228 @@ "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": "# Title Write your document in Markdown…" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentRequired": { + "defaultMessage": "Add some content to the document." + }, + "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.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.fileNotText": { + "defaultMessage": "The file could not be read as text." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.fileTooLarge": { + "defaultMessage": "The file is too large to upload." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.fileType": { + "defaultMessage": "Only Markdown files (.md, .markdown) can be uploaded." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.layout": { + "defaultMessage": "Editor layout", + "description": "Accessible name of the Write / Split / Preview switch." + }, + "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.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.nameRequired": { + "defaultMessage": "Enter a name for the document." + }, + "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.override": { + "defaultMessage": "Override", + "description": "Confirms replacing the document content with an uploaded file. Verb." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.overrideMessage": { + "defaultMessage": "Uploading \"{fileName}\" will replace the current content of \"{name}\". Any changes made in the editor will be lost." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.overrideTitle": { + "defaultMessage": "Override document content?" + }, + "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.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.DocumentEditor.upload": { + "defaultMessage": "Upload", + "description": "Button that loads a Markdown (.md) file from disk into the editor. Verb." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.uploadedFile": { + "defaultMessage": "Source file: {fileName}", + "description": "Accessible label of the chip naming the file the content came from." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.write": { + "defaultMessage": "Write" + }, + "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.showing": { + "defaultMessage": "Showing {shown} of {total}", + "description": "How many of the API’s documents are loaded into the list." + }, + "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.DocumentList.viewMore": { + "defaultMessage": "View more", + "description": "Loads the next page of documents into the list." + }, + "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.DocumentViewer.updated": { + "defaultMessage": "Updated {when}", + "description": "{when} is a relative time, e.g. \"2 days ago\"." + }, + "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 +2516,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 +4152,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 +4163,6 @@ "defaultMessage": "DEFAULT", "description": "Badge marking the organization’s default project." }, - "project.card.delete": { - "defaultMessage": "Delete" - }, "project.card.deleteNamed": { "defaultMessage": "Delete {name}" }, @@ -3973,9 +4184,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 +4234,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/overview/DocumentsPanel.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx new file mode 100644 index 0000000000..154e0188a6 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx @@ -0,0 +1,87 @@ +/* + * 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: 'HOW_TO', + 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('shows only a message when there are no documents', 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(); + }); + + 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". + expect(screen.queryByRole('link', { name: /View More/ })).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..f4caab4880 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,176 @@ /* * 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, + Button, + Card, + Chip, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronRight, FileText } 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 { REST_API_TYPE } from '@/api/resources/apiPublications'; +import { useFormatters } from '@/i18n/useFormatters'; +import { routes } from '@/routes/paths'; +import { useConsoleScope } from '@/scope/ConsoleScopeProvider'; +import { documentTypeLabel } 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.', + }, + 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…', + }, + loadError: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.loadError', + defaultMessage: 'Unable to load 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".', }, }); +/** 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; + return ( - - + + - - - - - - }> - {documents.map((document) => ( - - - - - {document.title} - - - {document.description} - - - - - - - - ))} + + + {documentsQuery.isPending ? ( + + + + ) : documentsQuery.error ? ( + + + + ) : total === 0 ? ( + + + + ) : ( + <> + + {documents.map((document, index) => { + const when = relativeTime(document.updatedAt ?? document.createdAt); + return ( + + {index > 0 && } + + + + + + + + + ); + })} + + {documentsPath && total > PREVIEW_COUNT && ( + <> + + + + + + )} + + )} ); } 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/documents/DocumentEditor.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx new file mode 100644 index 0000000000..b986702e49 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx @@ -0,0 +1,627 @@ +/* + * 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 { + Box, + Button, + Card, + Chip, + FormControl, + FormHelperText, + FormLabel, + MenuItem, + OutlinedInput, + PageTitle, + Paper, + Select, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@wso2/oxygen-ui'; +import { FileText, Upload } from '@wso2/oxygen-ui-icons-react'; +import { useId, useRef, useState, type ChangeEvent } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { isErrorCode, type ApiError } from '@/api/core/errors'; +import { + useApiDocument, + useApiDocumentContent, + useCreateApiDocument, + useUpdateApiDocument, + type ApiDocument, + type ApiDocumentType, + type UpdateApiDocumentBody, +} from '@/api/resources/apiDocuments'; +import { REST_API_TYPE } from '@/api/resources/apiPublications'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { MarkdownView } from '@/components/MarkdownView'; +import { useNotifications } from '@/components/Notifications'; +import { ErrorState, LoadingState } from '@/components/StateViews'; +import { segmentedSwitchSx } from '@/theme/receipes'; +import { isTextContent } from './documentContent'; +import { DEFAULT_DOCUMENT_TYPE, DOCUMENT_TYPES, documentTypeLabel } from './documentTypes'; +import { readMarkdownFile, suggestDocumentName, type MarkdownFileError } from './markdownFile'; + +const messages = defineMessages({ + createTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.createTitle', + defaultMessage: 'Create Document', + }, + createSubtitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.createSubtitle', + defaultMessage: 'Choose a type, name it and write the content in Markdown.', + }, + editTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.editTitle', + defaultMessage: 'Edit Document', + }, + editSubtitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.editSubtitle', + defaultMessage: 'Update the type, name or Markdown content of this document.', + }, + back: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.back', + defaultMessage: 'Back to documents', + }, + loading: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.loading', + defaultMessage: 'Loading document', + }, + notEditable: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.notEditable', + defaultMessage: 'This document’s format can’t be edited here.', + }, + loadError: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.loadError', + defaultMessage: 'Unable to load this document for editing.', + }, + typeLabel: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.typeLabel', + defaultMessage: 'Document type', + }, + nameLabel: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameLabel', + defaultMessage: 'Name', + description: 'Label for the document name field. Noun.', + }, + namePlaceholder: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.namePlaceholder', + defaultMessage: 'e.g. Getting started', + }, + nameRequired: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameRequired', + defaultMessage: 'Enter a name for the document.', + }, + contentLabel: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentLabel', + defaultMessage: 'Content', + }, + contentHint: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentHint', + defaultMessage: 'Markdown supported: headings, lists, links, code blocks and quotes.', + }, + contentPlaceholder: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentPlaceholder', + defaultMessage: '# Title\n\nWrite your document in Markdown…', + }, + contentRequired: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentRequired', + defaultMessage: 'Add some content to the document.', + }, + layout: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.layout', + defaultMessage: 'Editor layout', + description: 'Accessible name of the Write / Split / Preview switch.', + }, + write: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.write', + defaultMessage: 'Write', + }, + split: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.split', + defaultMessage: 'Split', + description: 'Editor layout showing the Markdown source and its preview side by side.', + }, + preview: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.preview', + defaultMessage: 'Preview', + }, + markdownPane: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.markdownPane', + defaultMessage: 'Markdown', + }, + previewEmpty: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.previewEmpty', + defaultMessage: 'Nothing to preview yet.', + }, + upload: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.upload', + defaultMessage: 'Upload', + description: 'Button that loads a Markdown (.md) file from disk into the editor. Verb.', + }, + uploadedFile: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.uploadedFile', + defaultMessage: 'Source file: {fileName}', + description: 'Accessible label of the chip naming the file the content came from.', + }, + fileType: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.fileType', + defaultMessage: 'Only Markdown files (.md, .markdown) can be uploaded.', + }, + fileTooLarge: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.fileTooLarge', + defaultMessage: 'The file is too large to upload.', + }, + fileNotText: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.fileNotText', + defaultMessage: 'The file could not be read as text.', + }, + overrideTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.overrideTitle', + defaultMessage: 'Override document content?', + }, + overrideMessage: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.overrideMessage', + defaultMessage: + 'Uploading "{fileName}" will replace the current content of "{name}". Any changes made in the editor will be lost.', + }, + override: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.override', + defaultMessage: 'Override', + description: 'Confirms replacing the document content with an uploaded file. Verb.', + }, + cancel: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.cancel', + defaultMessage: 'Cancel', + }, + create: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.create', + defaultMessage: 'Create', + description: 'Submits the new document. Verb.', + }, + save: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.save', + defaultMessage: 'Save', + description: 'Saves changes to the document. Verb.', + }, + saving: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.saving', + defaultMessage: 'Saving…', + }, + created: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.created', + defaultMessage: 'Created "{name}".', + }, + saved: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.saved', + defaultMessage: 'Saved "{name}".', + }, + tooLarge: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.tooLarge', + defaultMessage: 'The document is too large to save.', + }, +}); + +const FILE_ERROR_MESSAGE: Record = { + type: messages.fileType, + size: messages.fileTooLarge, + encoding: messages.fileNotText, +}; + +type Layout = 'write' | 'split' | 'preview'; + +/** Shared height of the Markdown editor and the preview; both scroll inside it. */ +const EDITOR_HEIGHT = 480; + +type DocumentEditorProps = { + apiHandle: string; + /** The document to edit; absent for a new document. */ + docId?: string; + onCancel: () => void; + onSaved: (docId: string) => void; +}; + +/** + * Create or edit form. In edit mode, loads the document's metadata and body + * (two requests) before showing the form, so it never opens half-filled. + */ +export function DocumentEditor({ apiHandle, docId, onCancel, onSaved }: DocumentEditorProps) { + const intl = useIntl(); + const documentQuery = useApiDocument(REST_API_TYPE, apiHandle, docId); + const contentQuery = useApiDocumentContent(REST_API_TYPE, apiHandle, docId); + + if (docId) { + if (documentQuery.isPending || contentQuery.isPending) { + return ; + } + if (documentQuery.error || contentQuery.error) { + return ; + } + // Saving a non-text body back as Markdown would corrupt it. + if (!isTextContent(contentQuery.data.contentType)) { + return ; + } + } + + return ( + + ); +} + +type DocumentFormProps = { + apiHandle: string; + existing?: ApiDocument; + /** The existing document's body; fetched separately from its metadata. */ + existingContent?: string; + onCancel: () => void; + onSaved: (docId: string) => void; +}; + +type FieldErrors = { displayName?: string; inlineContent?: string; type?: string }; + +function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onSaved }: DocumentFormProps) { + const intl = useIntl(); + const { notify } = useNotifications(); + const createMutation = useCreateApiDocument(); + const updateMutation = useUpdateApiDocument(); + const fileInput = useRef(null); + const typeLabelId = useId(); + const nameId = useId(); + const contentId = useId(); + + const [type, setType] = useState(existing?.type ?? DEFAULT_DOCUMENT_TYPE); + const [name, setName] = useState(existing?.displayName ?? ''); + const [content, setContent] = useState(existingContent); + const [fileName, setFileName] = useState(existing?.fileName ?? ''); + const [uploadedNew, setUploadedNew] = useState(false); + const [layout, setLayout] = useState('split'); + const [touched, setTouched] = useState(false); + const [serverErrors, setServerErrors] = useState({}); + const [pendingUpload, setPendingUpload] = useState<{ fileName: string; content: string } | null>(null); + + const isEdit = Boolean(existing); + const saving = createMutation.isPending || updateMutation.isPending; + const trimmedName = name.trim(); + + const errors: FieldErrors = { + displayName: + serverErrors.displayName ?? + (touched && !trimmedName ? intl.formatMessage(messages.nameRequired) : undefined), + inlineContent: + serverErrors.inlineContent ?? + (touched && !content.trim() ? intl.formatMessage(messages.contentRequired) : undefined), + type: serverErrors.type, + }; + + const contentChanged = content !== existingContent; + const dirty = isEdit + ? type !== existing!.type || trimmedName !== existing!.displayName || contentChanged + : true; + const valid = Boolean(trimmedName) && Boolean(content.trim()); + + const applyUpload = (upload: { fileName: string; content: string }) => { + setContent(upload.content); + setFileName(upload.fileName); + setUploadedNew(true); + setServerErrors((current) => ({ ...current, inlineContent: undefined })); + if (!trimmedName) setName(suggestDocumentName(upload.content, upload.fileName)); + }; + + const onFileChosen = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // Reset so choosing the same file again still fires a change event. + event.target.value = ''; + if (!file) return; + const result = await readMarkdownFile(file); + if ('error' in result) { + notify(intl.formatMessage(FILE_ERROR_MESSAGE[result.error]), 'error'); + return; + } + // Overwriting a saved document's content is confirmed first; a new + // document has nothing to lose. + if (isEdit) setPendingUpload(result); + else applyUpload(result); + }; + + const handleError = (error: ApiError) => { + const fieldErrors: FieldErrors = {}; + for (const fieldError of error.fieldErrors) { + if (fieldError.field === 'displayName' || fieldError.field === 'type') { + fieldErrors[fieldError.field] = fieldError.message; + } else if (fieldError.field === 'inlineContent' || fieldError.field === 'file') { + fieldErrors.inlineContent = fieldError.message; + } + } + setServerErrors(fieldErrors); + if (isErrorCode(error, 'PAYLOAD_TOO_LARGE')) { + notify(intl.formatMessage(messages.tooLarge), 'error'); + } + }; + + const save = () => { + setTouched(true); + setServerErrors({}); + if (!valid || saving) return; + + if (!existing) { + createMutation.mutate( + { + apiId: apiHandle, + apiType: REST_API_TYPE, + body: { + displayName: trimmedName, + fileName: fileName || undefined, + inlineContent: content, + type, + }, + }, + { + onError: handleError, + onSuccess: (created) => { + notify(intl.formatMessage(messages.created, { name: created.displayName }), 'success'); + onSaved(created.id); + }, + } + ); + return; + } + + // Only what changed is sent: omitting the content makes this a + // metadata-only update that leaves the stored bytes untouched. + const body: UpdateApiDocumentBody = { + displayName: trimmedName !== existing.displayName ? trimmedName : undefined, + fileName: uploadedNew && fileName ? fileName : undefined, + inlineContent: contentChanged ? content : undefined, + type: type !== existing.type ? type : undefined, + }; + updateMutation.mutate( + { apiId: apiHandle, apiType: REST_API_TYPE, body, docId: existing.id }, + { + onError: handleError, + onSuccess: (updated) => { + notify(intl.formatMessage(messages.saved, { name: updated.displayName }), 'success'); + onSaved(updated.id); + }, + } + ); + }; + + const showEditor = layout !== 'preview'; + const showPreview = layout !== 'write'; + + return ( + + + + + + + + + + + + + + + + + + + + + + {errors.type && {errors.type}} + + + + + + setTouched(true)} + onChange={(event) => { + setName(event.target.value); + setServerErrors((current) => ({ ...current, displayName: undefined })); + }} + placeholder={intl.formatMessage(messages.namePlaceholder)} + size="small" + value={name} + /> + {errors.displayName && {errors.displayName}} + + + + + + + + + + + + + + + {fileName && ( + } + label={fileName} + size="small" + sx={{ '& .MuiChip-label': { px: 1.25 }, height: 'auto', pl: 0.75, py: 0.5 }} + /> + )} + + void onFileChosen(event)} + ref={fileInput} + type="file" + /> + { + if (value) setLayout(value); + }} + size="small" + sx={segmentedSwitchSx} + value={layout} + > + + + + + + + + + + + + + + + {showEditor && ( + + setTouched(true)} + onChange={(event) => { + setContent(event.target.value); + setServerErrors((current) => ({ ...current, inlineContent: undefined })); + }} + placeholder={intl.formatMessage(messages.contentPlaceholder)} + // Fixed height; the textarea fills it and scrolls, rather than growing with the text. + sx={{ + '& textarea': { height: '100% !important', overflowY: 'auto !important' }, + alignItems: 'stretch', + fontFamily: 'monospace', + fontSize: '0.8125rem', + height: EDITOR_HEIGHT, + }} + value={content} + /> + + )} + {showPreview && ( + + + + + } + source={content} + /> + + )} + + {errors.inlineContent && {errors.inlineContent}} + + + + + {/* Pinned above the footer while the form scrolls; buttons only. */} + + + + + + + + + + setPendingUpload(null)} + onConfirm={() => { + if (pendingUpload) applyUpload(pendingUpload); + setPendingUpload(null); + }} + open={pendingUpload !== null} + title={intl.formatMessage(messages.overrideTitle)} + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentList.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentList.tsx new file mode 100644 index 0000000000..351cc38a1b --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentList.tsx @@ -0,0 +1,226 @@ +/* + * 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 { + Box, + Button, + Card, + Chip, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + Stack, + Typography, + alpha, + type Theme, +} from '@wso2/oxygen-ui'; +import { ChevronDown, FileText } from '@wso2/oxygen-ui-icons-react'; +import { Fragment, useMemo } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import type { ApiDocumentMetadata } from '@/api/resources/apiDocuments'; +import { useFormatters } from '@/i18n/useFormatters'; +import { hairline } from '@/theme/receipes'; +import { DOCUMENT_TYPES, documentTypeLabel } from './documentTypes'; + +const messages = defineMessages({ + heading: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.heading', + defaultMessage: 'All documents', + }, + total: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.total', + defaultMessage: '{count, plural, one {# document} other {# documents}}', + }, + updated: { + id: '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".', + }, + viewMore: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.viewMore', + defaultMessage: 'View more', + description: 'Loads the next page of documents into the list.', + }, + loadingMore: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.loadingMore', + defaultMessage: 'Loading documents…', + }, + showing: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.showing', + defaultMessage: 'Showing {shown} of {total}', + description: 'How many of the API’s documents are loaded into the list.', + }, +}); + +/** + * The theme's `action.selected` fill is close to invisible on the dark acrylic + * surface, so the selected document also gets a primary-coloured stroke. Every + * item carries a transparent border of the same width, so selecting one never + * shifts the list. + */ +const selectedItemSx = (theme: Theme) => + ({ + border: hairline(theme), + borderColor: 'transparent', + mb: 0.5, + '&.Mui-selected, &.Mui-selected:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + borderColor: 'primary.main', + }, + }) as const; + +type DocumentListProps = { + documents: ApiDocumentMetadata[]; + /** Fixed panel height; the list scrolls inside it. */ + height: Readonly>; + selectedId?: string; + /** Total across all pages, from `pagination.total`. */ + total: number; + hasMore: boolean; + loadingMore: boolean; + onLoadMore: () => void; + onSelect: (docId: string) => void; +}; + +/** + * The loaded documents, grouped by type, with "View more" appending the next + * page. Groups are rebuilt from whatever has loaded so far — the server orders + * by last update, so a later page can add to any group. + */ +export function DocumentList({ + documents, + hasMore, + height, + loadingMore, + onLoadMore, + onSelect, + selectedId, + total, +}: DocumentListProps) { + const intl = useIntl(); + const { relativeTime } = useFormatters(); + + const groups = useMemo(() => { + const known = new Set(DOCUMENT_TYPES); + return [...DOCUMENT_TYPES, 'UNKNOWN' as const] + .map((type) => ({ + type, + items: documents.filter((document) => + type === 'UNKNOWN' ? !known.has(document.type) : document.type === type + ), + })) + .filter((group) => group.items.length > 0); + }, [documents]); + + return ( + + + + + + + + + + + + {groups.map((group, groupIndex) => ( + + + + + + + + {group.items.map((document) => ( + onSelect(document.id)} + selected={document.id === selectedId} + sx={(theme) => ({ + ...selectedItemSx(theme), + alignItems: 'flex-start', + })} + > + + + + + + ))} + + ))} + + {(hasMore || documents.length < total) && ( + <> + + + {hasMore && ( + + )} + + + + + + + + )} + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentViewer.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentViewer.tsx new file mode 100644 index 0000000000..824f12b3f4 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentViewer.tsx @@ -0,0 +1,247 @@ +/* + * 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 { Box, Button, Card, Chip, Divider, Stack, Typography } from '@wso2/oxygen-ui'; +import { Pencil, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { REST_API_TYPE } from '@/api/resources/apiPublications'; +import { + useApiDocument, + useApiDocumentContent, + useDeleteApiDocument, +} from '@/api/resources/apiDocuments'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { MarkdownView } from '@/components/MarkdownView'; +import { useNotifications } from '@/components/Notifications'; +import { ErrorState, LoadingState } from '@/components/StateViews'; +import { useFormatters } from '@/i18n/useFormatters'; +import { Can } from '@/permissions'; +import { isErrorCode } from '@/api/core/errors'; +import { isTextContent } from './documentContent'; +import { documentTypeLabel } from './documentTypes'; + +const messages = defineMessages({ + loading: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.loading', + defaultMessage: 'Loading document', + }, + loadError: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.loadError', + defaultMessage: 'Unable to load this document.', + }, + notFound: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.notFound', + defaultMessage: 'This document no longer exists. It may have been deleted.', + }, + updated: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.updated', + defaultMessage: 'Updated {when}', + description: '{when} is a relative time, e.g. "2 days ago".', + }, + edit: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.edit', + defaultMessage: 'Edit', + }, + delete: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.delete', + defaultMessage: 'Delete', + }, + contentLoading: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.contentLoading', + defaultMessage: 'Loading content', + }, + contentError: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.contentError', + defaultMessage: 'Unable to load the content of this document.', + }, + unsupported: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.unsupported', + defaultMessage: 'This document’s format can’t be previewed here.', + }, + empty: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.empty', + defaultMessage: 'This document has no content.', + }, + deleteTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleteTitle', + defaultMessage: 'Delete document?', + }, + deleteMessage: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleteMessage', + defaultMessage: '"{name}" will be permanently removed from this API. This can’t be undone.', + }, + cancel: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.cancel', + defaultMessage: 'Cancel', + }, + deleted: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleted', + defaultMessage: 'Deleted "{name}".', + }, +}); + +type DocumentViewerProps = { + apiHandle: string; + docId: string; + onEdit: () => void; + onDeleted: () => void; +}; + +/** One document's metadata, its rendered Markdown, and the edit/delete actions. */ +export function DocumentViewer({ apiHandle, docId, onDeleted, onEdit }: DocumentViewerProps) { + const intl = useIntl(); + const { relativeTime } = useFormatters(); + const { notify } = useNotifications(); + // Metadata and body load in parallel: the header renders as soon as the + // metadata arrives, the body fills in below it. + const documentQuery = useApiDocument(REST_API_TYPE, apiHandle, docId); + const contentQuery = useApiDocumentContent(REST_API_TYPE, apiHandle, docId); + const deleteMutation = useDeleteApiDocument(); + const [confirmOpen, setConfirmOpen] = useState(false); + + if (documentQuery.isPending) { + return ( + + + + ); + } + if (documentQuery.error) { + return ( + + ); + } + + const document = documentQuery.data; + const when = relativeTime(document.updatedAt ?? document.createdAt); + + const confirmDelete = () => + deleteMutation.mutate( + { apiId: apiHandle, apiType: REST_API_TYPE, docId }, + { + onSuccess: () => { + setConfirmOpen(false); + notify(intl.formatMessage(messages.deleted, { name: document.displayName }), 'success'); + onDeleted(); + }, + } + ); + + return ( + + + + + + + + {document.displayName} + + {when && ( + + + + )} + + + + + + + + + + + + {/* Only the content scrolls; the title and actions stay in view. */} + + + {contentQuery.isPending ? ( + + ) : contentQuery.error ? ( + + + + ) : !isTextContent(contentQuery.data.contentType) ? ( + + + + ) : ( + + + + } + source={contentQuery.data.text} + /> + )} + + + + setConfirmOpen(false)} + onConfirm={confirmDelete} + open={confirmOpen} + title={intl.formatMessage(messages.deleteTitle)} + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsBrowser.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsBrowser.tsx new file mode 100644 index 0000000000..2649d953df --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsBrowser.tsx @@ -0,0 +1,170 @@ +/* + * 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 { Box, Button, Grid, PageTitle } from '@wso2/oxygen-ui'; +import { FileText, Plus } from '@wso2/oxygen-ui-icons-react'; +import { useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { REST_API_TYPE } from '@/api/resources/apiPublications'; +import { useApiDocumentPages } from '@/api/resources/apiDocuments'; +import { EmptyState, ErrorState, LoadingState } from '@/components/StateViews'; +import { Can } from '@/permissions'; +import { DocumentList } from './DocumentList'; +import { DocumentViewer } from './DocumentViewer'; + +const messages = defineMessages({ + title: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.title', + defaultMessage: 'Documents', + }, + subtitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.subtitle', + defaultMessage: 'Guides, samples and support resources that describe this API.', + }, + create: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.create', + defaultMessage: 'Create Document', + description: 'Button that opens the form for a new API document. Verb phrase.', + }, + loading: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.loading', + defaultMessage: 'Loading documents', + }, + loadError: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.loadError', + defaultMessage: 'Unable to load the documents for this API.', + }, + empty: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.empty', + defaultMessage: 'No Documents available for this API', + }, +}); + +/** Documents loaded per "View more". */ +export const DOCUMENTS_PAGE_SIZE = 10; + +/** + * Both panels share one fixed height and scroll inside it, so neither the + * number of documents nor the length of one grows the page. Sized to the + * viewport below the page title, with a floor for short windows. + */ +export const DOCUMENTS_PANEL_HEIGHT = { md: 'max(480px, calc(100vh - 300px))', xs: 560 } as const; + +type DocumentsBrowserProps = { + apiHandle: string; + /** The document in the URL; the first loaded one is shown when absent. */ + selectedId?: string; + onCreate: () => void; + onDeleted: () => void; + onEdit: (docId: string) => void; + onSelect: (docId: string, options?: { replace?: boolean }) => void; +}; + +/** The document list beside the selected document's content. */ +export function DocumentsBrowser({ + apiHandle, + onCreate, + onDeleted, + onEdit, + onSelect, + selectedId, +}: DocumentsBrowserProps) { + const intl = useIntl(); + const pagesQuery = useApiDocumentPages(REST_API_TYPE, apiHandle, { limit: DOCUMENTS_PAGE_SIZE }); + const firstId = pagesQuery.data?.pages[0]?.list[0]?.id; + + // Arriving without a document in the URL selects the first one, and records + // it (replacing, not pushing) so the highlight, the preview and the URL agree. + useEffect(() => { + if (!selectedId && firstId) onSelect(firstId, { replace: true }); + }, [firstId, onSelect, selectedId]); + + // `isPending`, not `isLoading`: a disabled query has no data and would flash the empty state. + if (pagesQuery.isPending) return ; + if (pagesQuery.error) return ; + + const pages = pagesQuery.data.pages; + const documents = pages.flatMap((page) => page.list); + // Lists read `pagination.total`, never `list.length`; the latest page has the freshest count. + const total = pages[pages.length - 1]?.pagination.total ?? 0; + const activeId = selectedId ?? documents[0]?.id; + const isEmpty = total === 0 && documents.length === 0; + + return ( + <> + + + + + + + + {/* The empty state carries its own create action; one way out is enough. */} + {!isEmpty && ( + + + + + + )} + + + {isEmpty ? ( + } + actionLabel={intl.formatMessage(messages.create)} + illustration={} + onAction={onCreate} + operationId="CreateAPIDocument" + title={intl.formatMessage(messages.empty)} + /> + ) : ( + + + void pagesQuery.fetchNextPage()} + onSelect={onSelect} + selectedId={activeId} + total={total} + /> + + + + {activeId && ( + onEdit(activeId)} + /> + )} + + + + )} + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.test.tsx new file mode 100644 index 0000000000..7a650899a0 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.test.tsx @@ -0,0 +1,202 @@ +/* + * 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, HttpResponse } from 'msw'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { ApiScopeProvider } from '@/api/core/ApiScopeProvider'; +import { resetHttpClient } from '@/api/core/http'; +import type { ApiDocument, ApiDocumentMetadata } from '@/api/resources/apiDocuments'; +import { routes } from '@/routes/paths'; +import { makeConsoleScope } from '@/test/mockScope'; +import { apiUrl, collection, recorder, type Recorder } from '@/test/msw'; +import { server } from '@/test/server'; +import { renderWithProviders, screen, waitFor, within } 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 BASE = routes.apiDevelopDocuments(ORG, PROJECT, API); + +/** A document fixture: metadata plus the body the content endpoint serves. */ +type DocumentFixture = ApiDocument & { content: string }; + +const aDocument = (id: string, overrides: Partial = {}): DocumentFixture => ({ + content: `# ${id}\n\nBody of ${id}.`, + contentType: 'text/markdown; charset=utf-8', + displayName: id, + id, + type: 'HOW_TO', + updatedAt: '2026-09-28T10:00:00Z', + ...overrides, +}); + +const metadata = (document: DocumentFixture): ApiDocumentMetadata => { + const { content, ...rest } = document; + void content; + return rest; +}; + +let requests: Recorder; + +const notFound = () => + HttpResponse.json({ code: 'NOT_FOUND', message: 'Not found', status: 'error' }, { status: 404 }); + +/** + * Serves the list (paged), each document's metadata and each document's body + * from one in-memory set — the metadata and the body are separate endpoints. + */ +function serve(documents: DocumentFixture[]) { + server.use( + collection(COLLECTION, documents.map(metadata), { record: requests }), + http.get(apiUrl(`${COLLECTION}/:docId/content`), ({ params }) => { + const document = documents.find((candidate) => candidate.id === params.docId); + return document + ? new HttpResponse(document.content, { headers: { 'Content-Type': document.contentType ?? '' } }) + : notFound(); + }), + http.get(apiUrl(`${COLLECTION}/:docId`), ({ params }) => { + const document = documents.find((candidate) => candidate.id === params.docId); + return document ? HttpResponse.json(metadata(document)) : notFound(); + }) + ); +} + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderPage(entry = BASE) { + return renderWithProviders( + + + + + + + } + path={routes.apiDevelopDocuments()} + /> + + , + { + route: entry, + scope: makeConsoleScope({ params: { apiHandler: API, orgHandle: ORG, projectHandler: PROJECT } }), + } + ); +} + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); +}); + +describe('DocumentsPanel', () => { + it('offers a single create action when the API has no documents', async () => { + serve([]); + renderPage(); + + expect(await screen.findByText('No Documents available for this API')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Create Document' })).toHaveLength(1); + }); + + it('shows the first document and switches when another is picked', async () => { + serve([aDocument('getting-started'), aDocument('sdk', { type: 'SAMPLE_SDK' })]); + const { user } = renderPage(); + + expect(await screen.findByRole('heading', { level: 2, name: 'getting-started' })).toBeInTheDocument(); + expect(await screen.findByText('Body of getting-started.')).toBeInTheDocument(); + expect(screen.getByText('Samples & SDK')).toBeInTheDocument(); + // The first document is selected on arrival, highlighted and in the URL. + expect(screen.getByRole('button', { name: /^getting-started/ })).toHaveAttribute('aria-current', 'true'); + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent(`${BASE}?doc=getting-started`) + ); + + await user.click(screen.getByRole('button', { name: /^sdk/ })); + + expect(await screen.findByText('Body of sdk.')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent(`${BASE}?doc=sdk`); + }); + + it('opens the document named in the URL', async () => { + serve([aDocument('getting-started'), aDocument('faq', { type: 'OTHER' })]); + renderPage(`${BASE}?doc=faq`); + + expect(await screen.findByText('Body of faq.')).toBeInTheDocument(); + }); + + it('loads the next page on "View more"', async () => { + serve(Array.from({ length: 12 }, (_, index) => aDocument(`doc-${index + 1}`))); + const { user } = renderPage(); + + expect(await screen.findByText('Showing 10 of 12')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'View more' })); + + expect(await screen.findByRole('button', { name: /^doc-12/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'View more' })).not.toBeInTheDocument(); + await waitFor(() => expect(requests.calls.filter((r) => r.method === 'GET' && r.url.pathname.endsWith('/docs')).at(-1)?.params.get('offset')).toBe('10')); + }); + + it('creates a document from inline Markdown and opens it', async () => { + const documents: DocumentFixture[] = []; + serve(documents); + server.use( + http.post(apiUrl(COLLECTION), async ({ request }) => { + await requests.capture(request); + const created = aDocument('error-handling', { displayName: 'Error handling' }); + documents.push(created); + return HttpResponse.json(metadata(created), { status: 201 }); + }) + ); + const { user } = renderPage(`${BASE}?mode=create`); + + await user.type(await screen.findByLabelText(/^Name/), 'Error handling'); + await user.type(screen.getByLabelText(/^Content/), 'Errors use a JSON body.'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent(`${BASE}?doc=error-handling`) + ); + expect(requests.calls.some((r) => r.method === 'POST')).toBe(true); + }); + + it('warns before an upload replaces an existing document’s content', async () => { + serve([aDocument('getting-started')]); + const { user } = renderPage(`${BASE}?doc=getting-started&mode=edit`); + + const content = await screen.findByLabelText(/^Content/); + expect(content).toHaveValue('# getting-started\n\nBody of getting-started.'); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(input, new File(['# Replaced'], 'replaced.md', { type: 'text/markdown' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Override document content?')).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Override' })); + + await waitFor(() => expect(content).toHaveValue('# Replaced')); + expect(screen.getByText('replaced.md')).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.tsx index 13ff26e370..8383c999e5 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentsPanel.tsx @@ -16,27 +16,62 @@ * under the License. */ -import { defineMessages, FormattedMessage } from 'react-intl'; - -import { ComingSoon } from '@/components/ComingSoon'; - -const messages = defineMessages({ - feature: { - id: 'apiControlPlane.pages.appShell.appShellPages.develop.DocumentsTab.feature', - defaultMessage: 'Documents for this API', - }, - detail: { - id: '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.', - }, -}); +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useConsoleScope } from '@/scope/ConsoleScopeProvider'; +import { DocumentEditor } from './DocumentEditor'; +import { DocumentsBrowser } from './DocumentsBrowser'; +import { documentsSearchParams, readDocumentsView, type DocumentsView } from './documentsSearch'; + +/** + * Develop › Documents for the API in scope. + * + * Switches between the browser (list + viewer) and the create/edit form from + * the URL's query string — see `documentsSearch.ts` — so every state can be + * linked to and Back leaves the editor. + */ export function DocumentsPanel() { + const { params } = useConsoleScope(); + const apiHandle = params.apiHandler; + const [searchParams, setSearchParams] = useSearchParams(); + const view = readDocumentsView(searchParams); + + const show = useCallback( + (next: DocumentsView, options: { replace?: boolean } = {}) => + setSearchParams(documentsSearchParams(next), options), + [setSearchParams] + ); + + const selectDocument = useCallback( + (docId: string, options?: { replace?: boolean }) => show({ docId, mode: 'browse' }, options), + [show] + ); + + // `ScopeGate` only renders this once an API is in scope. + if (!apiHandle) return null; + + if (view.mode === 'create' || view.mode === 'edit') { + return ( + show({ docId: view.mode === 'edit' ? view.docId : undefined, mode: 'browse' })} + // `replace`, so Back from the saved document does not reopen the form. + onSaved={(docId) => show({ docId, mode: 'browse' }, { replace: true })} + /> + ); + } + return ( - } - feature={} + show({ mode: 'create' })} + onDeleted={() => show({ mode: 'browse' }, { replace: true })} + onEdit={(docId) => show({ docId, mode: 'edit' })} + onSelect={selectDocument} + selectedId={view.docId} /> ); } diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentContent.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentContent.ts new file mode 100644 index 0000000000..ea307dbe09 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentContent.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +/** + * Whether a document body can be shown as Markdown and edited as text. + * + * The content endpoint returns raw bytes labelled with the stored content type, + * so a future non-text format (PDF, DOCX) must not be rendered — or worse, + * saved back — as Markdown. Markdown and plain text qualify; so does a missing + * type, which is what an empty (204) body carries. + */ +export const isTextContent = (contentType: string): boolean => { + const mediaType = contentType.split(';')[0].trim().toLowerCase(); + return mediaType === '' || mediaType === 'text/markdown' || mediaType === 'text/x-markdown' || mediaType === 'text/plain'; +}; diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentTypes.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentTypes.ts new file mode 100644 index 0000000000..742b005ed0 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentTypes.ts @@ -0,0 +1,76 @@ +/* + * 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 { defineMessages, type MessageDescriptor } from 'react-intl'; + +import type { ApiDocumentType } from '@/api/resources/apiDocuments'; + +const messages = defineMessages({ + howTo: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.howTo', + defaultMessage: 'How To', + description: 'Document type: a step-by-step guide.', + }, + sampleSdk: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.sampleSdk', + defaultMessage: 'Samples & SDK', + description: 'Document type: code samples and client SDKs.', + }, + publicForum: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.publicForum', + defaultMessage: 'Public Forum', + description: 'Document type: a link to or notes about a public community forum.', + }, + supportForum: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.supportForum', + defaultMessage: 'Support Forum', + description: 'Document type: a link to or notes about a support channel.', + }, + other: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.other', + defaultMessage: 'Other', + description: 'Document type: anything that fits no other type.', + }, +}); + +/** + * The user-authored document types, in the order they are offered and grouped. + * `satisfies` keeps this list in step with the spec's enum: a type added to the + * spec without a label here fails the type check rather than rendering blank. + */ +export const DOCUMENT_TYPES = [ + 'HOW_TO', + 'SAMPLE_SDK', + 'PUBLIC_FORUM', + 'SUPPORT_FORUM', + 'OTHER', +] as const satisfies readonly ApiDocumentType[]; + +const LABELS: Record = { + HOW_TO: messages.howTo, + SAMPLE_SDK: messages.sampleSdk, + PUBLIC_FORUM: messages.publicForum, + SUPPORT_FORUM: messages.supportForum, + OTHER: messages.other, +}; + +/** Label for a document type; an unknown value from a newer server falls back to "Other". */ +export const documentTypeLabel = (type: string): MessageDescriptor => + LABELS[type as ApiDocumentType] ?? messages.other; + +export const DEFAULT_DOCUMENT_TYPE: ApiDocumentType = 'HOW_TO'; diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsSearch.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsSearch.ts new file mode 100644 index 0000000000..d428c2c097 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsSearch.ts @@ -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. + */ + +/** + * Which part of the Documents page is showing, carried in the URL's query + * string so a document can be deep-linked (the API overview links straight to + * one), and so the browser's Back button steps out of the editor. + * + * ?doc= view one document + * ?mode=create the create form + * ?doc=&mode=edit the edit form for one document + * + * Query parameters rather than child routes: every state is the same sidebar + * entry and the same page, and adding routes would mean registering each one + * for every scope-less alias too. + */ + +export type DocumentsView = + | { mode: 'browse'; docId?: string } + | { mode: 'create' } + | { mode: 'edit'; docId: string }; + +const DOC_PARAM = 'doc'; +const MODE_PARAM = 'mode'; + +export const readDocumentsView = (params: URLSearchParams): DocumentsView => { + const docId = params.get(DOC_PARAM) || undefined; + const mode = params.get(MODE_PARAM); + if (mode === 'create') return { mode: 'create' }; + if (mode === 'edit' && docId) return { mode: 'edit', docId }; + return { mode: 'browse', docId }; +}; + +export const documentsSearchParams = (view: DocumentsView): URLSearchParams => { + const params = new URLSearchParams(); + if (view.mode !== 'create' && view.docId) params.set(DOC_PARAM, view.docId); + if (view.mode !== 'browse') params.set(MODE_PARAM, view.mode); + return params; +}; + +/** `?doc=…` suffix for a link into the Documents page; empty when there is nothing to select. */ +export const documentsSearch = (view: DocumentsView): string => { + const query = documentsSearchParams(view).toString(); + return query ? `?${query}` : ''; +}; diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsUtils.test.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsUtils.test.ts new file mode 100644 index 0000000000..622e6fc44f --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/documentsUtils.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { documentsSearch, readDocumentsView } from './documentsSearch'; +import { readMarkdownFile, suggestDocumentName } from './markdownFile'; + +describe('documents view in the URL', () => { + it.each([ + ['', { mode: 'browse', docId: undefined }], + ['doc=faq', { mode: 'browse', docId: 'faq' }], + ['mode=create', { mode: 'create' }], + ['doc=faq&mode=edit', { mode: 'edit', docId: 'faq' }], + // Edit without a document has nothing to edit. + ['mode=edit', { mode: 'browse', docId: undefined }], + ])('reads "%s"', (query, view) => { + expect(readDocumentsView(new URLSearchParams(query))).toEqual(view); + }); + + it('round-trips through documentsSearch', () => { + expect(documentsSearch({ docId: 'a b', mode: 'edit' })).toBe('?doc=a+b&mode=edit'); + expect(documentsSearch({ mode: 'browse' })).toBe(''); + }); +}); + +describe('readMarkdownFile', () => { + it('reads a Markdown file and strips a byte-order mark', async () => { + const file = new File(['\uFEFF# Hello'], 'hello.md'); + await expect(readMarkdownFile(file)).resolves.toEqual({ content: '# Hello', fileName: 'hello.md' }); + }); + + it('rejects other extensions', async () => { + await expect(readMarkdownFile(new File(['x'], 'notes.txt'))).resolves.toEqual({ error: 'type' }); + }); + + it('rejects binary content', async () => { + const file = new File([new Uint8Array([0x23, 0x00, 0x41])], 'binary.md'); + await expect(readMarkdownFile(file)).resolves.toEqual({ error: 'encoding' }); + }); +}); + +describe('suggestDocumentName', () => { + it('prefers the first top-level heading', () => { + expect(suggestDocumentName('intro\n# Error handling\n## More', 'x.md')).toBe('Error handling'); + }); + + it('falls back to a readable file name', () => { + expect(suggestDocumentName('no heading', 'getting-started_guide.md')).toBe('getting started guide'); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/markdownFile.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/markdownFile.ts new file mode 100644 index 0000000000..fcafe82249 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/markdownFile.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * Reading a Markdown file the user picks into the editor. + * + * The file never goes to the server as-is: its text is loaded into the editor + * so the user can review it, and is then saved as inline content like anything + * typed by hand. These checks are a courtesy that fails fast in the browser — + * platform-api still sniffs the content and enforces its own size limit. + */ + +/** Matches platform-api's default document size ceiling (5 MiB). */ +export const MAX_MARKDOWN_FILE_BYTES = 5 * 1024 * 1024; + +const MARKDOWN_EXTENSION = /\.(md|markdown)$/i; + +export type MarkdownFileError = 'type' | 'size' | 'encoding'; + +export type MarkdownFile = { fileName: string; content: string }; + +/** The file's bytes, via `FileReader` — supported everywhere `Blob.arrayBuffer` is not. */ +const readBytes = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error ?? new Error('read failed')); + reader.readAsArrayBuffer(file); + }); + +export async function readMarkdownFile(file: File): Promise { + if (!MARKDOWN_EXTENSION.test(file.name)) return { error: 'type' }; + if (file.size > MAX_MARKDOWN_FILE_BYTES) return { error: 'size' }; + + let content: string; + try { + // `fatal` makes invalid UTF-8 throw instead of being silently replaced. + content = new TextDecoder('utf-8', { fatal: true }).decode(await readBytes(file)); + } catch { + return { error: 'encoding' }; + } + // A NUL byte means binary content wearing a .md extension. + if (content.includes('\u0000')) return { error: 'encoding' }; + + // Only the base name is kept; a browser never exposes a path, but be explicit. + const fileName = file.name.split(/[\\/]/).pop() ?? file.name; + return { content: content.replace(/^\uFEFF/, ''), fileName }; +} + +/** + * A name for a document created from a file: its first `# Heading`, else the + * file name without its extension and with separators turned into spaces. + */ +export function suggestDocumentName(content: string, fileName: string): string { + const heading = /^ {0,3}#\s+(.+?)\s*#*\s*$/m.exec(content); + if (heading) return heading[1].trim(); + return fileName.replace(MARKDOWN_EXTENSION, '').replace(/[-_]+/g, ' ').trim(); +} From a3c0247174204ac5a95c1ae64443f367a3657700 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Fri, 2 Oct 2026 16:38:12 +0530 Subject: [PATCH 3/9] feat: support custom doc types for API documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename customDocType → otherTypeName across openapi.yaml, generated Go/TS types, DTO, handler, service, and frontend DocumentEditor - Store OTHER type docs using the bare user-typed name (e.g. "FAQ") instead of the previous OTHER_ prefix convention; update display and grouping logic accordingly - Introduce ap:docs:read and ap:docs:manage scopes; replace ap:rest_api:* scopes on all six /apis/{apiType}/{apiId}/docs endpoints - Validate otherTypeName against ForbiddenOtherTypeNames to block reserved (DEFINITION, THUMBNAIL) and fixed type names (HOW_TO, SAMPLE_SDK, etc.) — returns 400 instead of silently creating an invisible document - Add type NOT IN (reserved) guard to repo-layer DELETE and UPDATE SQL so reserved-type documents cannot be mutated through the user-facing path - Centralise the forbidden-type set in constants.ForbiddenOtherTypeNames (map for O(1) service validation; ranged over for SQL NOT IN in the repo) Co-Authored-By: Claude Sonnet 4.6 --- platform-api/api/generated.go | 23 +- platform-api/internal/constants/constants.go | 18 ++ platform-api/internal/dto/api_document.go | 12 +- platform-api/internal/handler/api_document.go | 28 +- .../internal/repository/api_document.go | 103 +++++++- .../internal/repository/interfaces.go | 1 + platform-api/internal/service/api_document.go | 68 +++-- platform-api/resources/openapi.yaml | 42 +-- .../src/api/generated/platform.d.ts | 19 +- .../src/i18n/messages/en.json | 50 +++- .../apis/overview/DocumentsPanel.tsx | 4 +- .../develop/documents/DocumentEditor.tsx | 250 ++++++++++++++---- .../develop/documents/DocumentList.tsx | 186 ++++++++----- .../develop/documents/DocumentViewer.tsx | 4 +- .../develop/documents/DocumentsPanel.test.tsx | 137 +++++++++- .../develop/documents/documentTypes.ts | 43 ++- .../develop/documents/documentsUtils.test.ts | 28 ++ 17 files changed, 790 insertions(+), 226 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 898a80dbb1..70d8a17ff0 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -2042,7 +2042,7 @@ type A2ATransportProtocolBinding string // APIDocumentCreateRequest Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and // `displayName` are required; exactly one of `file` or `inlineContent` -// must carry the body. `handle` is optional — the server generates one +// must carry the body. `id` is optional — the server generates one // from `displayName` when omitted. type APIDocumentCreateRequest struct { DisplayName string `json:"displayName" yaml:"displayName"` @@ -2053,12 +2053,18 @@ type APIDocumentCreateRequest struct { // FileName Optional file name to associate with `inlineContent`. Ignored when `file` is present (the uploaded file's name is used instead). FileName *string `json:"fileName,omitempty" yaml:"fileName,omitempty"` - // Handle Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. - Handle *string `json:"handle,omitempty" yaml:"handle,omitempty"` + // Id Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. + Id *string `json:"id,omitempty" yaml:"id,omitempty"` // InlineContent Inline UTF-8 content (markdown). Mutually exclusive with `file`. 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. + // Cannot be a reserved type name (DEFINITION, THUMBNAIL) or a fixed + // type name (HOW_TO, SAMPLE_SDK, PUBLIC_FORUM, SUPPORT_FORUM, OTHER). + OtherTypeName *string `json:"otherTypeName,omitempty" yaml:"otherTypeName,omitempty"` + // Type User-authored document type. DEFINITION/THUMBNAIL are reserved and // are managed via separate dedicated endpoints. Type APIDocumentType `json:"type" yaml:"type"` @@ -2088,10 +2094,9 @@ type APIDocumentMetadata struct { // Id URL-safe handle used in the `{docId}` path segment. Id string `json:"id" yaml:"id"` - // Type User-authored document type. DEFINITION/THUMBNAIL are reserved and - // are managed via separate dedicated endpoints. - Type APIDocumentType `json:"type" yaml:"type"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + // 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 `binding:"required" json:"type" yaml:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` // UpdatedBy User who updated the docuement. UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` @@ -2116,10 +2121,6 @@ type APIDocumentUpdateRequest struct { // InlineContent Replacement UTF-8 content. Mutually exclusive with `file`. InlineContent *string `json:"inlineContent,omitempty" yaml:"inlineContent,omitempty"` - - // Type User-authored document type. DEFINITION/THUMBNAIL are reserved and - // are managed via separate dedicated endpoints. - Type *APIDocumentType `json:"type,omitempty" yaml:"type,omitempty"` } // APIKeyItem defines model for APIKeyItem. diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index e08f1ed581..6265891d6f 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -183,6 +183,12 @@ 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" +) + // Custom Policy ManagedBy constants const ( PolicyManagedByOrganization = "organization" @@ -339,6 +345,18 @@ var ReservedAPIDocumentTypes = []string{ DocumentTypeThumbnail, } +// ForbiddenOtherTypeNames is the set of type names that may not be used as +// otherTypeName when creating a document with type=OTHER. +var ForbiddenOtherTypeNames = map[string]bool{ + DocumentTypeDefinition: true, + DocumentTypeThumbnail: true, + DocumentTypeHowTo: true, + DocumentTypeSampleAndSdk: true, + DocumentTypeSupportForum: true, + DocumentTypePublicForum: true, + 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 5c31f12a85..0d1909a7b6 100644 --- a/platform-api/internal/dto/api_document.go +++ b/platform-api/internal/dto/api_document.go @@ -20,11 +20,12 @@ 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. 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 @@ -38,7 +39,6 @@ type PutAPIDocumentRequest struct { } type UpdateAPIDocumentRequest struct { - Type *string DisplayName *string FileName *string Content []byte diff --git a/platform-api/internal/handler/api_document.go b/platform-api/internal/handler/api_document.go index 4c251be193..7fa82fd1c6 100644 --- a/platform-api/internal/handler/api_document.go +++ b/platform-api/internal/handler/api_document.go @@ -112,9 +112,6 @@ func (h *APIDocumentHandler) ListDocuments(w http.ResponseWriter, r *http.Reques return err } docType := strings.TrimSpace(r.URL.Query().Get("type")) - if docType != "" { - docType = strings.ToUpper(docType) - } limit, offset := parsePagination(r) docs, total, err := h.service.GetAllApiDocuments(artifactUUID, orgID, docType, limit, offset) @@ -200,7 +197,7 @@ func (h *APIDocumentHandler) GetDocumentContent(w http.ResponseWriter, r *http.R // CreateDocument handles POST /apis/{apiType}/{apiId}/docs. Expects a // multipart/form-data body with: type (required), displayName (required), -// handle (optional), and exactly one of file / inlineContent for the body. +// 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 { @@ -217,11 +214,12 @@ func (h *APIDocumentHandler) CreateDocument(w http.ResponseWriter, r *http.Reque } req := &dto.CreateAPIDocumentRequest{ - Type: strings.ToUpper(parsed.docType), - Handle: parsed.handle, - DisplayName: parsed.displayName, - FileName: parsed.fileName, - Content: parsed.content, + Type: strings.ToUpper(parsed.docType), + Handle: parsed.handle, + DisplayName: parsed.displayName, + FileName: parsed.fileName, + Content: parsed.content, + OtherTypeName: parsed.otherTypeName, } handle, err := h.service.CreateApiDocument(req, orgID, createdBy, artifactUUID) @@ -263,10 +261,6 @@ func (h *APIDocumentHandler) UpdateDocument(w http.ResponseWriter, r *http.Reque } req := &dto.UpdateAPIDocumentRequest{} - if parsed.docTypeSet { - upper := strings.ToUpper(parsed.docType) - req.Type = &upper - } if parsed.displayNameSet { req.DisplayName = &parsed.displayName } @@ -326,6 +320,7 @@ func (h *APIDocumentHandler) DeleteDocument(w http.ResponseWriter, r *http.Reque type parsedDocForm struct { docType string docTypeSet bool + otherTypeName string // only meaningful when docType == "OTHER" handle string displayName string displayNameSet bool @@ -361,9 +356,12 @@ func (h *APIDocumentHandler) parseDocMultipart(w http.ResponseWriter, r *http.Re parsed.docType = strings.TrimSpace(vals[0]) } } - if vals, ok := form.Value["handle"]; ok && len(vals) > 0 { + if vals, ok := form.Value["id"]; ok && 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 { @@ -441,7 +439,7 @@ func (h *APIDocumentHandler) parseDocMultipart(w http.ResponseWriter, r *http.Re func documentToAPIMetadata(d *model.Document) api.APIDocumentMetadata { return api.APIDocumentMetadata{ Id: d.Handle, - Type: api.APIDocumentType(d.Type), + Type: d.Type, DisplayName: d.DisplayName, FileName: optionalString(d.FileName), ContentType: optionalString(d.ContentType), diff --git a/platform-api/internal/repository/api_document.go b/platform-api/internal/repository/api_document.go index b76bddfb2a..e08892a265 100644 --- a/platform-api/internal/repository/api_document.go +++ b/platform-api/internal/repository/api_document.go @@ -143,10 +143,26 @@ func (r *DocumentRepo) ListDocumentsByArtifact(artifactUUID, orgUUID, docType st whereClause := `WHERE artifact_uuid = ? AND organization_uuid = ?` args := []interface{}{artifactUUID, orgUUID} if docType != "" { - whereClause += ` AND type = ?` - args = append(args, docType) - } - if len(constants.ReservedAPIDocumentTypes) > 0 { + if docType == constants.DocumentTypeOther { + placeholders := make([]string, 0, len(constants.ForbiddenOtherTypeNames)) + for t := range constants.ForbiddenOtherTypeNames { + placeholders = append(placeholders, "?") + args = append(args, t) + } + whereClause += ` AND type NOT IN (` + strings.Join(placeholders, ", ") + `)` + } else { + whereClause += ` AND type = ?` + args = append(args, docType) + 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, ", ") + `)` + } + } + } else if len(constants.ReservedAPIDocumentTypes) > 0 { placeholders := make([]string, len(constants.ReservedAPIDocumentTypes)) for i, t := range constants.ReservedAPIDocumentTypes { placeholders[i] = "?" @@ -244,8 +260,17 @@ func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { // UpdateDocument updates an existing document identified by artifact UUID + handle + org. // doc.Content is written only when updateContent is true, so a metadata-only PUT (no new file/inlineContent) // never overwrites the stored bytes with an empty payload. +// Reserved types (DEFINITION, THUMBNAIL) are excluded from the WHERE clause so +// user-facing callers can never mutate them through this path. func (r *DocumentRepo) UpdateDocument(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 @@ -256,25 +281,31 @@ func (r *DocumentRepo) UpdateDocument(doc *model.Document, updateContent bool) e UPDATE api_documents SET type = ?, display_name = ?, file_name = ?, content_type = ?, content = ?, updated_by = ?, updated_at = ? - WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ? - `) - result, err = r.db.Exec(query, + 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 = ? - `) - result, err = r.db.Exec(query, + 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) @@ -291,11 +322,27 @@ func (r *DocumentRepo) UpdateDocument(doc *model.Document, updateContent bool) e // 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) + 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 } @@ -317,6 +364,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 diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 7de1f852d4..021f19fa12 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -524,6 +524,7 @@ type DocumentRepository interface { UpdateDocument(doc *model.Document, updateContent bool) error DeleteDocument(artifactUUID, handle, orgUUID 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/service/api_document.go b/platform-api/internal/service/api_document.go index e4bbe64350..05afb7993d 100644 --- a/platform-api/internal/service/api_document.go +++ b/platform-api/internal/service/api_document.go @@ -18,6 +18,7 @@ package service import ( + "database/sql" "errors" "fmt" "log/slog" @@ -124,9 +125,17 @@ func (s *APIDocumentService) CreateDocument(req *dto.CreateAPIDocumentRequest, o return doc.Handle, nil } +// resolveStoredDocType computes the value to persist in the type column. +// For OTHER it stores the otherTypeName value directly (e.g. "FAQ"), so the +// OTHER_ prefix never appears in the database. Fixed types are stored as-is. +func resolveStoredDocType(docType, otherTypeName string) string { + if docType != constants.DocumentTypeOther { + return docType + } + return strings.TrimSpace(otherTypeName) +} + // CreateApiDocument creates a user-authored document attached to an artifact. -// Validates the caller-supplied type against ValidAPIDocumentUserTypes -// (so reserved types cannot be reached through this path entry) func (s *APIDocumentService) CreateApiDocument(req *dto.CreateAPIDocumentRequest, orgID, userID, artifactUUID string) (string, error) { if req == nil { return "", apperror.ValidationFailed.New("document request is required") @@ -137,9 +146,27 @@ func (s *APIDocumentService) CreateApiDocument(req *dto.CreateAPIDocumentRequest if !constants.ValidAPIDocumentUserTypes[req.Type] { return "", apperror.ValidationFailed.New("invalid document type") } + if req.Type == constants.DocumentTypeOther { + trimmed := strings.TrimSpace(req.OtherTypeName) + if trimmed == "" { + return "", apperror.ValidationFailed.New("otherTypeName is required when type is OTHER") + } + if constants.ForbiddenOtherTypeNames[strings.ToUpper(trimmed)] { + return "", apperror.ValidationFailed.New("otherTypeName cannot be a reserved or fixed document type name") + } + } + req.Type = resolveStoredDocType(req.Type, req.OtherTypeName) if strings.TrimSpace(req.DisplayName) == "" { return "", apperror.ValidationFailed.New("displayName is required") } + nameExists, nameErr := s.documentRepo.DocumentDisplayNameExistsForArtifact(artifactUUID, req.DisplayName, "") + if nameErr != nil { + s.slogger.Error("Failed to check document display name existence", "artifactUUID", artifactUUID, "error", nameErr) + return "", apperror.Internal.Wrap(nameErr).WithLogMessage("failed to validate document display name") + } + if nameExists { + return "", apperror.Conflict.New().WithLogMessage("document display name already exists for artifact") + } if req.Handle != "" { exists, existsErr := s.documentRepo.DocumentHandleExistsForArtifact(artifactUUID, req.Handle) if existsErr != nil { @@ -241,6 +268,9 @@ func (s *APIDocumentService) DeleteApiDocument(artifactUUID, handle, orgID, user } if err := s.documentRepo.DeleteDocument(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 } @@ -257,12 +287,6 @@ func (s *APIDocumentService) GetAllApiDocuments(artifactUUID, orgID, docType str if artifactUUID == "" { return nil, 0, apperror.ValidationFailed.New("artifact UUID is required") } - // if docType is supplied, it must be a valid non reserved doc type - if docType != "" { - if !constants.ValidAPIDocumentUserTypes[docType] { - return []*model.Document{}, 0, nil - } - } docs, total, err := s.documentRepo.ListDocumentsByArtifact(artifactUUID, orgID, docType, limit, offset) if err != nil { @@ -328,33 +352,35 @@ func (s *APIDocumentService) UpdateApiDocument(req *dto.UpdateAPIDocumentRequest return apperror.NotFound.New() } - merged := *existing - merged.UpdatedBy = userID - if req.Type != nil { - if !constants.ValidAPIDocumentUserTypes[*req.Type] { - return apperror.ValidationFailed.New("invalid document type") - } - merged.Type = *req.Type - } + updatedDocument := *existing + updatedDocument.UpdatedBy = userID if req.DisplayName != nil { trimmed := strings.TrimSpace(*req.DisplayName) if trimmed == "" { return apperror.ValidationFailed.New("displayName must not be empty") } - merged.DisplayName = trimmed + nameExists, nameErr := s.documentRepo.DocumentDisplayNameExistsForArtifact(artifactUUID, trimmed, handle) + if nameErr != nil { + s.slogger.Error("Failed to check document display name existence", "artifactUUID", artifactUUID, "error", nameErr) + return apperror.Internal.Wrap(nameErr).WithLogMessage("failed to validate document display name") + } + if nameExists { + return apperror.Conflict.New().WithLogMessage("document display name already exists for artifact") + } + updatedDocument.DisplayName = trimmed } if req.FileName != nil { - merged.FileName = *req.FileName + updatedDocument.FileName = *req.FileName } updateContent := req.Content != nil if updateContent { - merged.Content = req.Content + updatedDocument.Content = req.Content if req.ContentType != nil { - merged.ContentType = *req.ContentType + updatedDocument.ContentType = *req.ContentType } } - if err := s.documentRepo.UpdateDocument(&merged, updateContent); err != nil { + if err := s.documentRepo.UpdateDocument(&updatedDocument, updateContent); err != nil { s.slogger.Error("Failed to update document", "artifactUUID", artifactUUID, "handle", handle, "error", err) return err } diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index c6680a1c4f..386c63b9a4 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1936,9 +1936,8 @@ paths: operationId: ListAPIDocuments security: - OAuth2Security: - # TODO - - ap:rest_api:read - - ap:rest_api:manage + - ap:docs:read + - ap:docs:manage tags: - API Documents parameters: @@ -1967,8 +1966,7 @@ paths: operationId: CreateAPIDocument security: - OAuth2Security: - - ap:rest_api:create - - ap:rest_api:manage + - ap:docs:manage tags: - API Documents requestBody: @@ -2017,8 +2015,8 @@ paths: operationId: GetAPIDocument security: - OAuth2Security: - - ap:rest_api:read - - ap:rest_api:manage + - ap:docs:read + - ap:docs:manage tags: - API Documents responses: @@ -2043,8 +2041,7 @@ paths: operationId: UpdateAPIDocument security: - OAuth2Security: - - ap:rest_api:update - - ap:rest_api:manage + - ap:docs:manage tags: - API Documents requestBody: @@ -2079,8 +2076,7 @@ paths: operationId: DeleteAPIDocument security: - OAuth2Security: - - ap:rest_api:delete - - ap:rest_api:manage + - ap:docs:manage tags: - API Documents responses: @@ -2112,8 +2108,8 @@ paths: operationId: GetAPIDocumentContent security: - OAuth2Security: - - ap:rest_api:read - - ap:rest_api:manage + - ap:docs:read + - ap:docs:manage tags: - API Documents responses: @@ -7576,6 +7572,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 @@ -9219,7 +9217,9 @@ components: maxLength: 40 example: "payment-webhook-howto" type: - $ref: '#/components/schemas/APIDocumentType' + type: 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 displayName: type: string example: Payment Webhook How-To @@ -9267,7 +9267,7 @@ components: description: | Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and `displayName` are required; exactly one of `file` or `inlineContent` - must carry the body. `handle` is optional — the server generates one + must carry the body. `id` is optional — the server generates one from `displayName` when omitted. required: - type @@ -9275,10 +9275,18 @@ components: properties: type: $ref: '#/components/schemas/APIDocumentType' + 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. + Cannot be a reserved type name (DEFINITION, THUMBNAIL) or a fixed + type name (HOW_TO, SAMPLE_SDK, PUBLIC_FORUM, SUPPORT_FORUM, OTHER). + example: FAQ displayName: type: string example: Payment Webhook How-To - handle: + id: type: string description: Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. example: payment-webhook-howto @@ -9302,8 +9310,6 @@ components: Supplying neither `file` nor `inlineContent` means a metadata-only update — the stored bytes are not touched. properties: - type: - $ref: '#/components/schemas/APIDocumentType' displayName: type: string example: Payment Webhook How-To (v2) 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 d56e60f713..616f3629ae 100644 --- a/portals/api-control-plane/src/api/generated/platform.d.ts +++ b/portals/api-control-plane/src/api/generated/platform.d.ts @@ -4014,7 +4014,11 @@ export interface components { * @example payment-webhook-howto */ id: string; - type: components["schemas"]["APIDocumentType"]; + /** + * @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; /** @@ -4048,18 +4052,26 @@ export interface components { /** * @description Multipart form for `POST /apis/{apiType}/{apiId}/docs`. `type` and * `displayName` are required; exactly one of `file` or `inlineContent` - * must carry the body. `handle` is optional — the server generates one + * must carry the body. `id` is optional — the server generates one * from `displayName` when omitted. */ APIDocumentCreateRequest: { type: components["schemas"]["APIDocumentType"]; + /** + * @description Free-form qualifier used when `type` is `OTHER`. Stored and returned + * exactly as typed (no case conversion). Ignored for all other types. + * Cannot be a reserved type name (DEFINITION, THUMBNAIL) or a fixed + * type name (HOW_TO, SAMPLE_SDK, PUBLIC_FORUM, SUPPORT_FORUM, OTHER). + * @example FAQ + */ + otherTypeName?: string; /** @example Payment Webhook How-To */ displayName: string; /** * @description Optional URL-safe handle. Must be unique per artifact; a conflict returns 409. * @example payment-webhook-howto */ - handle?: string; + id?: string; /** * Format: binary * @description Uploaded document bytes. Mutually exclusive with `inlineContent`. @@ -4080,7 +4092,6 @@ export interface components { * update — the stored bytes are not touched. */ APIDocumentUpdateRequest: { - type?: components["schemas"]["APIDocumentType"]; /** @example Payment Webhook How-To (v2) */ displayName?: string; /** diff --git a/portals/api-control-plane/src/i18n/messages/en.json b/portals/api-control-plane/src/i18n/messages/en.json index 0c2699e289..ff1a9cf7a9 100644 --- a/portals/api-control-plane/src/i18n/messages/en.json +++ b/portals/api-control-plane/src/i18n/messages/en.json @@ -1461,10 +1461,7 @@ "defaultMessage": "Content" }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentPlaceholder": { - "defaultMessage": "# Title Write your document in Markdown…" - }, - "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentRequired": { - "defaultMessage": "Add some content to the document." + "defaultMessage": "# Write your document in Markdown…" }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.create": { "defaultMessage": "Create", @@ -1479,6 +1476,22 @@ "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.created": { "defaultMessage": "Created \"{name}\"." }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeHint": { + "defaultMessage": "Up to {max} characters." + }, + "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.customTypeTooLong": { + "defaultMessage": "Use {max} characters or fewer." + }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.editSubtitle": { "defaultMessage": "Update the type, name or Markdown content of this document." }, @@ -1496,7 +1509,17 @@ }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.layout": { "defaultMessage": "Editor layout", - "description": "Accessible name of the Write / Split / Preview switch." + "description": "Accessible name of the Source / Split / Preview switch." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leave": { + "defaultMessage": "Leave", + "description": "Confirms leaving the document form and discarding unsaved changes. Verb." + }, + "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.leaveTitle": { + "defaultMessage": "Discard changes?" }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.loadError": { "defaultMessage": "Unable to load this document for editing." @@ -1514,9 +1537,6 @@ "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.namePlaceholder": { "defaultMessage": "e.g. Getting started" }, - "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameRequired": { - "defaultMessage": "Enter a name for the document." - }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.notEditable": { "defaultMessage": "This document’s format can’t be edited here." }, @@ -1546,10 +1566,17 @@ "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.stay": { + "defaultMessage": "Stay", + "description": "Keeps the user on the document form. Verb." + }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.tooLarge": { "defaultMessage": "The document is too large to save." }, @@ -1564,9 +1591,6 @@ "defaultMessage": "Source file: {fileName}", "description": "Accessible label of the chip naming the file the content came from." }, - "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.write": { - "defaultMessage": "Write" - }, "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.heading": { "defaultMessage": "All documents" }, @@ -1577,6 +1601,10 @@ "defaultMessage": "Showing {shown} of {total}", "description": "How many of the API’s documents are loaded into the list." }, + "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}}" }, 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 f4caab4880..d7c4d5b1db 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 @@ -38,7 +38,7 @@ import { REST_API_TYPE } from '@/api/resources/apiPublications'; import { useFormatters } from '@/i18n/useFormatters'; import { routes } from '@/routes/paths'; import { useConsoleScope } from '@/scope/ConsoleScopeProvider'; -import { documentTypeLabel } from '../../develop/documents/documentTypes'; +import { documentTypeName } from '../../develop/documents/documentTypes'; import { documentsSearch } from '../../develop/documents/documentsSearch'; const messages = defineMessages({ @@ -144,7 +144,7 @@ export function DocumentsPanel() { sx={{ minWidth: 0 }} /> diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx index b986702e49..9729035222 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/documents/DocumentEditor.tsx @@ -35,7 +35,7 @@ import { Typography, } from '@wso2/oxygen-ui'; import { FileText, Upload } from '@wso2/oxygen-ui-icons-react'; -import { useId, useRef, useState, type ChangeEvent } from 'react'; +import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { isErrorCode, type ApiError } from '@/api/core/errors'; @@ -55,7 +55,15 @@ import { useNotifications } from '@/components/Notifications'; import { ErrorState, LoadingState } from '@/components/StateViews'; import { segmentedSwitchSx } from '@/theme/receipes'; import { isTextContent } from './documentContent'; -import { DEFAULT_DOCUMENT_TYPE, DOCUMENT_TYPES, documentTypeLabel } from './documentTypes'; +import { + DEFAULT_DOCUMENT_TYPE, + DOCUMENT_TYPES, + MAX_CUSTOM_TYPE_LENGTH, + documentTypeLabel, + documentTypeName, + validateCustomType, + type CustomTypeError, +} from './documentTypes'; import { readMarkdownFile, suggestDocumentName, type MarkdownFileError } from './markdownFile'; const messages = defineMessages({ @@ -95,6 +103,27 @@ const messages = defineMessages({ id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.typeLabel', defaultMessage: 'Document type', }, + customTypeLabel: { + id: '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".', + }, + customTypePlaceholder: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypePlaceholder', + defaultMessage: 'e.g. Changelog', + }, + customTypeHint: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeHint', + defaultMessage: 'Up to {max} characters.', + }, + customTypeTooLong: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeTooLong', + defaultMessage: 'Use {max} characters or fewer.', + }, + customTypeInvalid: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeInvalid', + defaultMessage: 'Use only letters, numbers, spaces, hyphens and underscores.', + }, nameLabel: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameLabel', defaultMessage: 'Name', @@ -104,10 +133,6 @@ const messages = defineMessages({ id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.namePlaceholder', defaultMessage: 'e.g. Getting started', }, - nameRequired: { - id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameRequired', - defaultMessage: 'Enter a name for the document.', - }, contentLabel: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentLabel', defaultMessage: 'Content', @@ -118,20 +143,16 @@ const messages = defineMessages({ }, contentPlaceholder: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentPlaceholder', - defaultMessage: '# Title\n\nWrite your document in Markdown…', - }, - contentRequired: { - id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentRequired', - defaultMessage: 'Add some content to the document.', + defaultMessage: '# Write your document in Markdown…', }, layout: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.layout', defaultMessage: 'Editor layout', - description: 'Accessible name of the Write / Split / Preview switch.', + description: 'Accessible name of the Source / Split / Preview switch.', }, - write: { - id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.write', - defaultMessage: 'Write', + source: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.source', + defaultMessage: 'Source', }, split: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.split', @@ -186,6 +207,24 @@ const messages = defineMessages({ defaultMessage: 'Override', description: 'Confirms replacing the document content with an uploaded file. Verb.', }, + leaveTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveTitle', + defaultMessage: 'Discard changes?', + }, + leaveMessage: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveMessage', + defaultMessage: 'You have unsaved changes. Are you sure you want to leave?', + }, + leave: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leave', + defaultMessage: 'Leave', + description: 'Confirms leaving the document form and discarding unsaved changes. Verb.', + }, + stay: { + id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.stay', + defaultMessage: 'Stay', + description: 'Keeps the user on the document form. Verb.', + }, cancel: { id: 'apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.cancel', defaultMessage: 'Cancel', @@ -224,7 +263,23 @@ const FILE_ERROR_MESSAGE: Record = encoding: messages.fileNotText, }; -type Layout = 'write' | 'split' | 'preview'; +/** + * Format problems only. A *missing* required field shows no error — Create / + * Save simply stays disabled until every required field has a value. + */ +const CUSTOM_TYPE_ERROR_MESSAGE: Record, typeof messages.customTypeTooLong> = { + tooLong: messages.customTypeTooLong, + invalid: messages.customTypeInvalid, +}; + +type Layout = 'source' | 'split' | 'preview'; + +/** + * One height for every single-line field in the top row. A small Select and a + * small OutlinedInput otherwise differ by a few pixels, which shows as the + * boxes not lining up. + */ +const FIELD_SX = { height: 40 } as const; /** Shared height of the Markdown editor and the preview; both scroll inside it. */ const EDITOR_HEIGHT = 480; @@ -292,14 +347,18 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS const typeLabelId = useId(); const nameId = useId(); const contentId = useId(); + const customTypeId = useId(); - const [type, setType] = useState(existing?.type ?? DEFAULT_DOCUMENT_TYPE); + // Only used when creating: an existing document's type is fixed and is + // shown read-only straight from the response. + const [type, setType] = useState(DEFAULT_DOCUMENT_TYPE); + const [customType, setCustomType] = useState(''); const [name, setName] = useState(existing?.displayName ?? ''); const [content, setContent] = useState(existingContent); const [fileName, setFileName] = useState(existing?.fileName ?? ''); const [uploadedNew, setUploadedNew] = useState(false); const [layout, setLayout] = useState('split'); - const [touched, setTouched] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); const [serverErrors, setServerErrors] = useState({}); const [pendingUpload, setPendingUpload] = useState<{ fileName: string; content: string } | null>(null); @@ -307,21 +366,37 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS const saving = createMutation.isPending || updateMutation.isPending; const trimmedName = name.trim(); - const errors: FieldErrors = { - displayName: - serverErrors.displayName ?? - (touched && !trimmedName ? intl.formatMessage(messages.nameRequired) : undefined), - inlineContent: - serverErrors.inlineContent ?? - (touched && !content.trim() ? intl.formatMessage(messages.contentRequired) : undefined), - type: serverErrors.type, - }; + const errors: FieldErrors = serverErrors; + const isOther = type === 'OTHER'; + const customTypeError = isOther ? validateCustomType(customType) : undefined; + // A format problem (too long, bad characters) is shown as the user types; an + // empty value is not an error to show, it just keeps the button disabled. + const customTypeFormatError = customTypeError === 'required' ? undefined : customTypeError; const contentChanged = content !== existingContent; const dirty = isEdit - ? type !== existing!.type || trimmedName !== existing!.displayName || contentChanged + ? trimmedName !== existing!.displayName || contentChanged : true; - const valid = Boolean(trimmedName) && Boolean(content.trim()); + const hasUnsavedChanges = isEdit + ? dirty || uploadedNew + : Boolean(trimmedName || content.trim() || customType.trim() || fileName) || + type !== DEFAULT_DOCUMENT_TYPE; + + // Cancel and Back ask first when there is something to lose. + const requestCancel = () => { + if (hasUnsavedChanges) setConfirmLeave(true); + else onCancel(); + }; + + // Closing or reloading the tab gets the browser's own "leave site?" prompt. + useEffect(() => { + if (!hasUnsavedChanges) return undefined; + const warn = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [hasUnsavedChanges]); + + const valid = Boolean(trimmedName) && Boolean(content.trim()) && !customTypeError; const applyUpload = (upload: { fileName: string; content: string }) => { setContent(upload.content); @@ -363,7 +438,6 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS }; const save = () => { - setTouched(true); setServerErrors({}); if (!valid || saving) return; @@ -377,6 +451,7 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS fileName: fileName || undefined, inlineContent: content, type, + otherTypeName: isOther ? customType.trim() : undefined, }, }, { @@ -392,11 +467,11 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS // Only what changed is sent: omitting the content makes this a // metadata-only update that leaves the stored bytes untouched. + // Type cannot be changed after creation. const body: UpdateApiDocumentBody = { displayName: trimmedName !== existing.displayName ? trimmedName : undefined, fileName: uploadedNew && fileName ? fileName : undefined, inlineContent: contentChanged ? content : undefined, - type: type !== existing.type ? type : undefined, }; updateMutation.mutate( { apiId: apiHandle, apiType: REST_API_TYPE, body, docId: existing.id }, @@ -411,12 +486,12 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS }; const showEditor = layout !== 'preview'; - const showPreview = layout !== 'write'; + const showPreview = layout !== 'source'; return ( - + @@ -429,40 +504,89 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS - - + + - + {isEdit ? ( + + ) : ( + + )} {errors.type && {errors.type}} - + {/* Asked for only when creating; edit shows the custom name in the type field. */} + {isOther && !isEdit && ( + + + + + { + setCustomType(event.target.value); + setServerErrors((current) => ({ ...current, type: undefined })); + }} + placeholder={intl.formatMessage(messages.customTypePlaceholder)} + size="small" + sx={FIELD_SX} + value={customType} + /> + + {customTypeFormatError ? ( + + ) : ( + + )} + + + )} + setTouched(true)} onChange={(event) => { setName(event.target.value); setServerErrors((current) => ({ ...current, displayName: undefined })); }} placeholder={intl.formatMessage(messages.namePlaceholder)} size="small" + sx={FIELD_SX} value={name} /> {errors.displayName && {errors.displayName}} @@ -520,8 +644,8 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS sx={segmentedSwitchSx} value={layout} > - - + + @@ -543,13 +667,11 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS inputProps={{ spellCheck: false }} multiline rows={1} - onBlur={() => setTouched(true)} - onChange={(event) => { + onChange={(event) => { setContent(event.target.value); setServerErrors((current) => ({ ...current, inlineContent: undefined })); }} placeholder={intl.formatMessage(messages.contentPlaceholder)} - // Fixed height; the textarea fills it and scrolls, rather than growing with the text. sx={{ '& textarea': { height: '100% !important', overflowY: 'auto !important' }, alignItems: 'stretch', @@ -588,11 +710,11 @@ function DocumentForm({ apiHandle, existing, existingContent = '', onCancel, onS -