From 2ebb4140061516bdac1c176d94fc184630778cce Mon Sep 17 00:00:00 2001 From: Ayesha <88117894+Ayeshas09@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:09:25 +0500 Subject: [PATCH 1/7] feat: add support to expand webhooks and callbacks in toc (#216) Co-authored-by: Asad Ali <14asadali@gmail.com> --- .prettierrc | 2 +- src/actions/portal/toc/new-toc.ts | 125 ++++++----- .../portal/toc/toc-structure-generator.ts | 184 ++++++++++------ src/commands/portal/toc/new.ts | 82 +++++--- src/prompts/portal/toc/new-toc.ts | 24 ++- src/types/sdl/sdl.ts | 198 +++++++++++++++--- src/types/toc/toc.ts | 48 ++++- src/utils/string-utils.ts | 48 ++++- 8 files changed, 515 insertions(+), 196 deletions(-) diff --git a/.prettierrc b/.prettierrc index ff60f9df..0eeb9a6f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { "parser": "typescript", "printWidth": 120, - "singleQuote": false, + "singleQuote": true, "trailingComma": "none", "tabWidth": 2 } diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 72fca7b9..cae2d354 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -1,17 +1,22 @@ -import { ok } from "neverthrow"; -import { PortalNewTocPrompts } from "../../../prompts/portal/toc/new-toc.js"; -import { TocStructureGenerator } from "../../../application/portal/toc/toc-structure-generator.js"; -import { TocEndpoint, TocGroup, TocModel } from "../../../types/toc/toc.js"; -import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { CommandMetadata } from "../../../types/common/command-metadata.js"; -import { ActionResult } from "../../action-result.js"; -import { TocContext } from "../../../types/toc-context.js"; -import { FileService } from "../../../infrastructure/file-service.js"; -import { BuildContext } from "../../../types/build-context.js"; -import { getEndpointGroupsAndModels } from "../../../types/sdl/sdl.js"; -import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; -import { TempContext } from "../../../types/temp-context.js"; -import { PortalService } from "../../../infrastructure/services/portal-service.js"; +import { PortalNewTocPrompts } from '../../../prompts/portal/toc/new-toc.js'; +import { TocStructureGenerator } from '../../../application/portal/toc/toc-structure-generator.js'; +import { TocGroup } from '../../../types/toc/toc.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { ActionResult } from '../../action-result.js'; +import { TocContext } from '../../../types/toc-context.js'; +import { FileService } from '../../../infrastructure/file-service.js'; +import { BuildContext } from '../../../types/build-context.js'; +import { + extractCallbacksForToc, + extractEndpointGroupsForToc, + extractModelsForToc, + extractWebhooksForToc, + SdlTocComponents +} from '../../../types/sdl/sdl.js'; +import { withDirPath } from '../../../infrastructure/tmp-extensions.js'; +import { TempContext } from '../../../types/temp-context.js'; +import { PortalService } from '../../../infrastructure/services/portal-service.js'; export class ContentContext { private readonly fileService = new FileService(); @@ -28,11 +33,6 @@ export class ContentContext { } } -type SdlComponents = { - endpointGroups: Map; - models: TocModel[]; -}; - export class PortalNewTocAction { private readonly prompts: PortalNewTocPrompts = new PortalNewTocPrompts(); private readonly tocGenerator: TocStructureGenerator = new TocStructureGenerator(); @@ -46,7 +46,9 @@ export class PortalNewTocAction { tocDirectory?: DirectoryPath, force: boolean = false, expandEndpoints: boolean = false, - expandModels: boolean = false + expandModels: boolean = false, + expandWebhooks: boolean = false, + expandCallbacks: boolean = false ): Promise { // Validate build directory const buildContext = new BuildContext(buildDirectory); @@ -55,7 +57,7 @@ export class PortalNewTocAction { return ActionResult.failed(); } const buildConfig = await buildContext.getBuildFileContents(); - const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? "content"); + const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? 'content'); const tocDir = tocDirectory ?? contentDirectory; const tocContext = new TocContext(tocDir); @@ -65,38 +67,50 @@ export class PortalNewTocAction { return ActionResult.cancelled(); } - let sdlComponents: SdlComponents = { endpointGroups: new Map(), models: [] }; - if (expandEndpoints || expandModels) { - const specDirectory = buildDirectory.join("spec"); + const sdlTocComponents: SdlTocComponents = await (async () => { + const defaultComponents = { + endpointGroups: new Map(), + models: [], + webhookGroups: new Map(), + callbackGroups: new Map() + }; + + if (!expandEndpoints && !expandModels && !expandWebhooks && !expandCallbacks) { + return defaultComponents; + } + + const specDirectory = buildDirectory.join('spec'); if (!(await this.fileService.directoryExists(specDirectory))) { this.prompts.fallingBackToDefault(); - } else { - const sdlResult = await withDirPath(async (tempDirectory) => { - const tempContext = new TempContext(tempDirectory); - const specZipPath = await tempContext.zip(specDirectory); - const specFileStream = await this.fileService.getStream(specZipPath); - try { - const result = await this.prompts.extractEndpointGroupsAndModels( - this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata) - ); - if (result.isErr()) { - this.prompts.fallingBackToDefault(); - return ok({ endpointGroups: new Map(), models: [] } as SdlComponents); - } - return ok(getEndpointGroupsAndModels(result.value)); - } finally { - specFileStream.close(); - } - }); - - if (sdlResult.isErr()) { - this.prompts.logError(sdlResult.error); - return ActionResult.failed(); - } - sdlComponents = sdlResult.value; + return defaultComponents; } - } + + return await withDirPath(async (tempDirectory) => { + const tempContext = new TempContext(tempDirectory); + const specZipPath = await tempContext.zip(specDirectory); + const specFileStream = await this.fileService.getStream(specZipPath); + const result = await this.prompts.extractComponents( + this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata), + expandEndpoints, + expandModels, + expandWebhooks, + expandCallbacks + ); + specFileStream.close(); + if (result.isErr()) { + this.prompts.fallingBackToDefault(); + return defaultComponents; + } + + const endpointGroups = expandEndpoints ? extractEndpointGroupsForToc(result.value) : new Map(); + const models = expandModels ? extractModelsForToc(result.value) : []; + const webhookGroups = expandWebhooks ? extractWebhooksForToc(result.value) : new Map(); + const callbackGroups = expandCallbacks ? extractCallbacksForToc(result.value) : new Map(); + + return { endpointGroups, models, webhookGroups, callbackGroups }; + }); + })(); const contentContext = new ContentContext(contentDirectory); const contentExists = await contentContext.exists(); @@ -108,20 +122,17 @@ export class PortalNewTocAction { contentGroups = await contentContext.extractContentGroups(); } - const toc = this.tocGenerator.createTocStructure( - sdlComponents.endpointGroups, - sdlComponents.models, - expandEndpoints, - expandModels, + sdlTocComponents.endpointGroups, + sdlTocComponents.models, + sdlTocComponents.webhookGroups, + sdlTocComponents.callbackGroups, contentGroups ); - const yamlString = this.tocGenerator.transformToYaml(toc); const tocFilePath = await tocContext.save(yamlString); - - this.prompts.tocCreated(tocFilePath) + this.prompts.tocCreated(tocFilePath); return ActionResult.success(); } diff --git a/src/application/portal/toc/toc-structure-generator.ts b/src/application/portal/toc/toc-structure-generator.ts index 76b8376c..954b09d5 100644 --- a/src/application/portal/toc/toc-structure-generator.ts +++ b/src/application/portal/toc/toc-structure-generator.ts @@ -1,95 +1,147 @@ -import { stringify } from "yaml"; -import { Toc, TocGroup, TocEndpoint, TocModel, TocEndpointGroupOverview } from "../../../types/toc/toc.js"; +import { stringify } from 'yaml'; +import { + Toc, + TocGroup, + TocEndpointGroupOverview, + TocModelPage, + TocGenerated, + TocCallbackPage, + TocWebhookPage, + TocEndpoint +} from '../../../types/toc/toc.js'; + +// TODO: Refactor export class TocStructureGenerator { createTocStructure( endpointGroups: Map, - models: TocModel[], - expandEndpoints: boolean = false, - expandModels: boolean = false, + models: TocModelPage[], + webhookGroups: Map, + callbackGroups: Map, contentGroups: TocGroup[] = [] ): Toc { - const tocStructure: Toc = { - toc: [] - }; - - // Add Getting Started section - tocStructure.toc.push({ - group: "Getting Started", - items: [ + return { + toc: [ { - generate: "How to Get Started", - from: "getting-started" + group: 'Getting Started', + items: [ + { + generate: 'How to Get Started', + from: 'getting-started' + } + ] + }, + ...contentGroups, + this.getEndpointsSection(endpointGroups), + { + group: 'Events', + items: [this.getCallbacksSection(callbackGroups), this.getWebhooksSection(webhookGroups)] + }, + this.getModelsSection(models), + { + generate: 'SDK Infrastructure', + from: 'sdk-infra' } ] + }; + } + + transformToYaml(toc: Toc): string { + const transformedToc = this.transformKeys(toc); + return stringify(transformedToc, { + indent: 2, + nullStr: '' }); + } - // Add content groups - if (contentGroups.length > 0) { - tocStructure.toc.push(...contentGroups); + private getEndpointsSection(endpointGroups: Map): TocGroup | TocGenerated { + if (endpointGroups.size === 0) { + return { + generate: 'API Endpoints', + from: 'endpoints' + }; } + return { + group: 'API Endpoints', + items: Array.from(endpointGroups).map(([groupName, endpoints]) => ({ + group: groupName, + items: [ + { + generate: null, + from: 'endpoint-group-overview', + endpointGroup: groupName + } as TocEndpointGroupOverview, + ...endpoints + ] + })) + }; + } - // Add API Endpoints section - if (!expandEndpoints || endpointGroups.size === 0) { - tocStructure.toc.push({ - generate: "API Endpoints", - from: "endpoints" - }); - } else { - tocStructure.toc.push({ - group: "API Endpoints", - items: Array.from(endpointGroups).map(([groupName, endpoints]) => ({ - group: groupName, - items: [ - { - generate: null, - from: "endpoint-group-overview", - endpointGroup: groupName - } as TocEndpointGroupOverview, - ...endpoints - ] - })) - }); + private getCallbacksSection(callbackGroups: Map): TocGroup | TocGenerated { + if (callbackGroups.size === 0) { + return { + generate: null, + from: 'callbacks' + }; } - - // Add Models section - if (!expandModels || models.length === 0) { - tocStructure.toc.push({ - generate: "Models", - from: "models" - }); - } else { - tocStructure.toc.push({ - group: "Models", - items: models - }); + if (callbackGroups.size === 1) { + return { + group: Array.from(callbackGroups.keys())[0], + items: Array.from(callbackGroups.values())[0] + }; } + return { + group: 'Callbacks', + items: Array.from(callbackGroups).map(([groupName, eventList]) => ({ + group: groupName, + items: eventList + })) + }; + } - //Add Sdk Infra section - tocStructure.toc.push({ - generate: "SDK Infrastructure", - from: "sdk-infra" - }); - - return tocStructure; + private getWebhooksSection(webhookGroups: Map): TocGroup | TocGenerated { + if (webhookGroups.size === 0) { + return { + generate: null, + from: 'webhooks' + }; + } + if (webhookGroups.size === 1) { + return { + group: Array.from(webhookGroups.keys())[0], + items: Array.from(webhookGroups.values())[0] + }; + } + return { + group: 'Webhooks', + items: Array.from(webhookGroups).map(([groupName, eventList]) => ({ + group: groupName, + items: eventList + })) + }; } - transformToYaml(toc: Toc): string { - const transformedToc = this.transformKeys(toc); - return stringify(transformedToc, { - indent: 2, - nullStr: "" - }); + private getModelsSection(models: TocModelPage[]): TocGroup | TocGenerated { + if (models.length === 0) { + return { + generate: 'Models', + from: 'models' + }; + } + return { + group: 'Models', + items: models + }; } private transformKeys(obj: any): any { if (Array.isArray(obj)) { return obj.map((item) => this.transformKeys(item)); } - if (obj !== null && typeof obj === "object") { + if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj).map(([key, value]) => [ - key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), + key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(), this.transformKeys(value) ]) ); diff --git a/src/commands/portal/toc/new.ts b/src/commands/portal/toc/new.ts index d7992ead..41a5ec74 100644 --- a/src/commands/portal/toc/new.ts +++ b/src/commands/portal/toc/new.ts @@ -1,14 +1,14 @@ -import { Command, Flags } from "@oclif/core"; -import { PortalNewTocAction } from "../../../actions/portal/toc/new-toc.js"; -import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js"; -import { TocCreationFailedEvent } from "../../../types/events/toc-creation-failed.js"; -import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { FlagsProvider } from "../../../types/flags-provider.js"; -import { CommandMetadata } from "../../../types/common/command-metadata.js"; -import { format, intro, outro } from "../../../prompts/format.js"; +import { Command, Flags } from '@oclif/core'; +import { PortalNewTocAction } from '../../../actions/portal/toc/new-toc.js'; +import { TelemetryService } from '../../../infrastructure/services/telemetry-service.js'; +import { TocCreationFailedEvent } from '../../../types/events/toc-creation-failed.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { FlagsProvider } from '../../../types/flags-provider.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { format, intro, outro } from '../../../prompts/format.js'; export default class PortalTocNew extends Command { - static summary = "Generate a Table of Contents (TOC) file for your API documentation portal"; + static summary = 'Generate a Table of Contents (TOC) file for your API documentation portal'; static description = `This command generates a new Table of Contents (TOC) file used in the generation of your API documentation portal. @@ -17,41 +17,61 @@ The output is a YAML file with the .yml extension. To learn more about the TOC file and APIMatic build directory structure, visit: ${format.link( - "https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal" + 'https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal' )}`; static flags = { - ...FlagsProvider.destination("src/content", `toc.yml`), + ...FlagsProvider.destination('src/content', `toc.yml`), ...FlagsProvider.input, ...FlagsProvider.force, - "expand-endpoints": Flags.boolean({ + 'expand-endpoints': Flags.boolean({ default: false, description: `include individual entries for each endpoint in the generated ${format.var( - "toc.yml" + 'toc.yml' )}. Requires a valid API specification in the working directory.` }), - "expand-models": Flags.boolean({ + 'expand-models': Flags.boolean({ default: false, description: `include individual entries for each model in the generated ${format.var( - "toc.yml" + 'toc.yml' + )}. Requires a valid API specification in the working directory.` + }), + 'expand-webhooks': Flags.boolean({ + default: false, + description: `include individual entries for each webhook in the generated ${format.var( + 'toc.yml' + )}. Requires a valid API specification in the working directory.` + }), + 'expand-callbacks': Flags.boolean({ + default: false, + description: `include individual entries for each callback in the generated ${format.var( + 'toc.yml' )}. Requires a valid API specification in the working directory.` }) }; - static cmdTxt = format.cmd("apimatic", "portal", "toc", "new"); + static cmdTxt = format.cmd('apimatic', 'portal', 'toc', 'new'); static examples = [ - `${this.cmdTxt} ${format.flag("destination", './src/content/')}`, - `${this.cmdTxt} ${format.flag("input", './')}`, - `${this.cmdTxt} ${format.flag("input", './')} ${format.flag("destination", './src/content/')}` + `${this.cmdTxt} ${format.flag('destination', './src/content/')}`, + `${this.cmdTxt} ${format.flag('input', './')}`, + `${this.cmdTxt} ${format.flag('input', './')} ${format.flag('destination', './src/content/')}` ]; async run(): Promise { const { - flags: { input, destination, force, "expand-endpoints": expandEndpoints, "expand-models": expandModels } + flags: { + input, + destination, + force, + 'expand-endpoints': expandEndpoints, + 'expand-models': expandModels, + 'expand-webhooks': expandWebhooks, + 'expand-callbacks': expandCallbacks + } } = await this.parse(PortalTocNew); const workingDirectory = DirectoryPath.createInput(input); - const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + const buildDirectory = input ? new DirectoryPath(input, 'src') : workingDirectory.join('src'); const tocDirectory = destination ? new DirectoryPath(destination) : undefined; const commandMetadata: CommandMetadata = { @@ -59,9 +79,17 @@ ${format.link( shell: this.config.shell }; - intro("New TOC"); + intro('New TOC'); const action = new PortalNewTocAction(new DirectoryPath(this.config.configDir), commandMetadata); - const result = await action.execute(buildDirectory, tocDirectory, force, expandEndpoints, expandModels); + const result = await action.execute( + buildDirectory, + tocDirectory, + force, + expandEndpoints, + expandModels, + expandWebhooks, + expandCallbacks + ); outro(result); result.mapAll( @@ -70,12 +98,14 @@ ${format.link( const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); await telemetryService.trackEvent( // TODO: fix Toc error message - new TocCreationFailedEvent("error", PortalTocNew.id, { + new TocCreationFailedEvent('error', PortalTocNew.id, { input, destination, force, - "expand-endpoints": expandEndpoints, - "expand-models": expandModels + 'expand-endpoints': expandEndpoints, + 'expand-models': expandModels, + 'expand-webhooks': expandWebhooks, + 'expand-callbacks': expandCallbacks }), commandMetadata.shell ); diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index afdf39d2..8f668429 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -42,13 +42,23 @@ export class PortalNewTocPrompts { log.error(message); } - public extractEndpointGroupsAndModels(fn: Promise>) { - return withSpinner( - "Extracting endpoint groups and models", - "Endpoint groups and models extracted", - "Endpoint groups and models extraction failed", - fn - ); + public extractComponents( + fn: Promise>, + expandEndpoints: boolean, + expandModels: boolean, + expandWebhooks: boolean, + expandCallbacks: boolean + ): Promise> { + const components = [ + expandEndpoints && "Endpoint groups", + expandModels && "Models", + expandWebhooks && "Webhooks", + expandCallbacks && "Callbacks" + ] + .filter(Boolean) + .join(" and "); + + return withSpinner(`Extracting ${components}`, `${components} extracted`, `${components} extraction failed`, fn); } public tocCreated(tocPath: FilePath) { diff --git a/src/types/sdl/sdl.ts b/src/types/sdl/sdl.ts index 3ee71e95..528397d7 100644 --- a/src/types/sdl/sdl.ts +++ b/src/types/sdl/sdl.ts @@ -1,30 +1,76 @@ -import { TocEndpoint, TocModel } from "../toc/toc.js"; +import { TocEndpoint, TocModelPage, TocCallback, TocWebhook, TocWebhookPage, TocCallbackPage } from '../toc/toc.js'; +import { toTitleCase } from '../../utils/string-utils.js'; + export type EndpointGroup = Map; -export type SdlTocComponents = { endpointGroups: EndpointGroup; models: TocModel[] }; +export type WebhookGroup = Map; +export type CallbackGroup = Map; +export type SdlTocComponents = { + endpointGroups: EndpointGroup; + models: TocModelPage[]; + webhookGroups: WebhookGroup; + callbackGroups: CallbackGroup; +}; export interface Sdl { readonly Endpoints: SdlEndpoint[]; readonly CustomTypes: SdlModel[]; + readonly Webhooks: SdlWebhook[]; } export interface SdlEndpoint { readonly Name: string; readonly Description: string; readonly Group: string; + readonly Callbacks: SdlCallback[]; } export interface SdlModel { readonly Name: string; } -function extractEndpointGroupsForToc(sdl: Sdl): Map { +export interface SdlCallback { + readonly Id: string; + readonly CallbackGroupName?: string; +} + +export interface SdlWebhook { + readonly Id: string; + readonly WebhookGroupName?: string; +} + +export function getEndpointDescription( + endpointGroups: Map, + endpointGroupName: string, + endpointName: string +): string { + return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; +} + +export function getEndpointGroupsFromSdl(sdl: Sdl): Map { + const endpointGroups = new Map(); + for (const endpoint of sdl.Endpoints) { + if (!endpointGroups.has(endpoint.Group)) { + endpointGroups.set(endpoint.Group, []); + } + + endpointGroups.get(endpoint.Group)!.push({ + Name: endpoint.Name, + Description: endpoint.Description, + Group: endpoint.Group, + Callbacks: endpoint.Callbacks + }); + } + return endpointGroups; +} + +export function extractEndpointGroupsForToc(sdl: Sdl): Map { const endpointGroups = new Map(); const endpoints = sdl.Endpoints.map( (e: SdlEndpoint): TocEndpoint => ({ generate: null, - from: "endpoint", + from: 'endpoint', endpointName: e.Name, endpointGroup: e.Group }) @@ -41,42 +87,136 @@ function extractEndpointGroupsForToc(sdl: Sdl): Map { return endpointGroups; } -function extractModelsForToc(sdl: Sdl): TocModel[] { +export function extractModelsForToc(sdl: Sdl): TocModelPage[] { return sdl.CustomTypes.map( - (e: SdlModel): TocModel => ({ + (e: SdlModel): TocModelPage => ({ generate: null, - from: "model", + from: 'model', modelName: e.Name }) ); } -export function getEndpointGroupsAndModels(sdl: Sdl): SdlTocComponents { - const endpointGroups = extractEndpointGroupsForToc(sdl); - const models = extractModelsForToc(sdl); - return { endpointGroups, models }; -} +export function extractWebhooksForToc(sdl: Sdl): Map { + if (sdl.Webhooks.length === 0) { + return new Map(); + } -export function getEndpointDescription( - endpointGroups: Map, - endpointGroupName: string, - endpointName: string -): string { - return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; + let groupedWebhooks = new Map(); + const ungrouped: TocWebhook[] = []; + + for (const webhook of sdl.Webhooks) { + const event: TocWebhook = { + generate: null, + from: 'webhook', + webhookName: webhook.Id, + webhookGroup: webhook.WebhookGroupName ?? null + }; + + if (webhook.WebhookGroupName) { + const groupTitle = toTitleCase(webhook.WebhookGroupName); + if (!groupedWebhooks.has(groupTitle)) { + groupedWebhooks.set(groupTitle, [ + { + generate: null, + from: 'webhook-group-overview', + webhookGroup: webhook.WebhookGroupName + } + ]); + } + groupedWebhooks.get(groupTitle)!.push(event); + } else { + ungrouped.push(event); + } + } + + let ungroupedWebhooks = new Map(); + + if (ungrouped.length > 0) { + const uniqueGroupName = getUniqueGroupName('Webhooks', new Set(groupedWebhooks.keys())); + const uniqueGroupTitle = toTitleCase(uniqueGroupName); + ungroupedWebhooks.set(uniqueGroupTitle, [ + { + generate: null, + from: 'webhook-group-overview', + webhookGroup: uniqueGroupName + }, + ...ungrouped.map( + (event): TocWebhook => ({ + ...event, + webhookGroup: uniqueGroupName + }) + ) + ]); + } + + return new Map([...[...groupedWebhooks].sort((a, b) => a[0].localeCompare(b[0])), ...ungroupedWebhooks]); } -export function getEndpointGroupsFromSdl(sdl: Sdl): Map { - const endpointGroups = new Map(); - for (const endpoint of sdl.Endpoints) { - if (!endpointGroups.has(endpoint.Group)) { - endpointGroups.set(endpoint.Group, []); +export function extractCallbacksForToc(sdl: Sdl): Map { + if (sdl.Endpoints.length === 0) { + return new Map(); } - endpointGroups.get(endpoint.Group)!.push({ - Name: endpoint.Name, - Description: endpoint.Description, - Group: endpoint.Group - }); + let groupedCallbacks = new Map(); + const ungrouped: TocCallback[] = []; + + for (const callback of sdl.Endpoints.flatMap((e) => e.Callbacks)) { + const event: TocCallback = { + generate: null, + from: 'callback', + callbackName: callback.Id, + callbackGroup: callback.CallbackGroupName ?? null + }; + + if (callback.CallbackGroupName) { + const groupTitle = toTitleCase(callback.CallbackGroupName); + if (!groupedCallbacks.has(groupTitle)) { + groupedCallbacks.set(groupTitle, [ + { + generate: null, + from: 'callback-group-overview', + callbackGroup: callback.CallbackGroupName + } + ]); + } + groupedCallbacks.get(groupTitle)!.push(event); + } else { + ungrouped.push(event); + } + } + + let ungroupedCallbacks = new Map(); + + if (ungrouped.length > 0) { + const uniqueGroupName = getUniqueGroupName('Callbacks', new Set(groupedCallbacks.keys())); + const uniqueGroupTitle = toTitleCase(uniqueGroupName); + ungroupedCallbacks.set(uniqueGroupTitle, [ + { + generate: null, + from: 'callback-group-overview', + callbackGroup: uniqueGroupName + }, + ...ungrouped.map( + (event): TocCallback => ({ + ...event, + callbackGroup: uniqueGroupName + }) + ) + ]); + } + + return new Map([...[...groupedCallbacks].sort((a, b) => a[0].localeCompare(b[0])), ...ungroupedCallbacks]); } -return endpointGroups; + +function getUniqueGroupName(baseName: string, existingGroups: Set): string { + let counter = 1; + let name = baseName; + + while (existingGroups.has(toTitleCase(name))) { + name = `${baseName}${counter}`; + counter++; + } + + return name; } diff --git a/src/types/toc/toc.ts b/src/types/toc/toc.ts index b9f76b61..f20127d7 100644 --- a/src/types/toc/toc.ts +++ b/src/types/toc/toc.ts @@ -1,36 +1,68 @@ export interface Toc { - toc: Array + toc: Array; } export interface TocGroup { - readonly group: string, - readonly items: Array + readonly group: string; + readonly items: Array< + TocGroup | TocGenerated | TocEndpointPage | TocWebhookPage | TocCallbackPage | TocModelPage | TocCustomPage + >; } export interface TocGenerated { - readonly generate: string; + readonly generate: string | null; readonly from: string; } +export type TocEndpointPage = TocEndpoint | TocEndpointGroupOverview; +export type TocWebhookPage = TocWebhook | TocWebhookOverview; +export type TocCallbackPage = TocCallback | TocCallbackOverview; + export interface TocEndpointGroupOverview { readonly generate: null; - readonly from: "endpoint-group-overview"; + readonly from: 'endpoint-group-overview'; readonly endpointGroup: string; } export interface TocEndpoint { readonly generate: null; - readonly from: "endpoint"; + readonly from: 'endpoint'; readonly endpointName: string; readonly endpointGroup: string; } -export interface TocModel { +export interface TocModelPage { readonly generate: null; - readonly from: "model"; + readonly from: 'model'; readonly modelName: string; } +export interface TocWebhookOverview { + readonly generate: null; + readonly from: 'webhook-group-overview'; + readonly webhookGroup: string | null; +} + +export interface TocWebhook { + readonly generate: null; + readonly from: 'webhook'; + readonly webhookName: string; + readonly webhookGroup: string | null; +} + +export interface TocCallbackOverview { + readonly generate: null; + readonly from: 'callback-group-overview'; + readonly callbackGroup: string | null; +} + +export interface TocCallback { + readonly generate: null; + readonly from: 'callback'; + readonly callbackName: string; + readonly callbackGroup: string | null; +} + export interface TocCustomPage { readonly page: string; readonly file: string; diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts index dafaa7d0..1fabb218 100644 --- a/src/utils/string-utils.ts +++ b/src/utils/string-utils.ts @@ -9,8 +9,6 @@ export const removeQuotes = (input: string): string => { return input; }; - - export function stripAnsi(str: string) { let result = ''; let i = 0; @@ -38,3 +36,49 @@ export function stripAnsi(str: string) { return result; } +export function toTitleCase(str: string): string { + if (str === '') { + return ''; + } + + let result = ''; + let shouldCapitalizeNext = true; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + const prevChar = i > 0 ? str[i - 1] : ''; + + if (isLowercase(char)) { + result += shouldCapitalizeNext ? ' ' + char.toUpperCase() : char; + shouldCapitalizeNext = false; + } else if (isUppercase(char)) { + if (prevChar && !isUppercase(prevChar)) { + result += ' '; + } + result += char; + shouldCapitalizeNext = false; + } else if (isDigit(char)) { + if (prevChar && !isDigit(prevChar)) { + result += ' '; + } + result += char; + shouldCapitalizeNext = true; + } else { + shouldCapitalizeNext = true; + } + } + + return result.trim(); +} + +function isLowercase(char: string): boolean { + return char >= 'a' && char <= 'z'; +} + +function isUppercase(char: string): boolean { + return char >= 'A' && char <= 'Z'; +} + +function isDigit(char: string): boolean { + return char.length === 1 && char >= '0' && char <= '9'; +} From 858f5e8cb7115695a4f653b41c53a22de3c8c11d Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:13:24 +0500 Subject: [PATCH 2/7] feat: add support for pruning spec and use v2 validation endpoint (#217) --------- Co-authored-by: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> --- README.md | 2 +- package-lock.json | 4 +- src/actions/action-result.ts | 76 ++++++--- src/actions/api/validate.ts | 26 +++- src/actions/portal/quickstart.ts | 34 +++- src/actions/sdk/quickstart.ts | 20 +++ src/infrastructure/services/portal-service.ts | 2 +- .../services/validation-service.ts | 145 ++++++++++++++++-- src/prompts/api/validate.ts | 49 +++++- src/prompts/format.ts | 5 +- src/prompts/portal/quickstart.ts | 47 +++++- src/prompts/sdk/quickstart.ts | 50 +++++- 12 files changed, 405 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 42ca6573..4f2f022b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ npm install -g @apimatic/cli $ apimatic COMMAND running command... $ apimatic (--version) -@apimatic/cli/1.0.0-beta.1 win32-x64 node-v23.4.0 +@apimatic/cli/1.1.0-beta.3 win32-x64 node-v23.4.0 $ apimatic --help [COMMAND] USAGE $ apimatic COMMAND diff --git a/package-lock.json b/package-lock.json index aca762c7..e916f8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apimatic/cli", - "version": "1.0.0-beta.1", + "version": "1.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apimatic/cli", - "version": "1.0.0-beta.1", + "version": "1.1.0-beta.2", "license": "MIT", "dependencies": { "@apimatic/sdk": "^0.2.0-alpha.5", diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 49752bca..4aaea8fc 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,54 +1,94 @@ enum ResultType { - Success= 0, + Success = 0, Cancel = 130, - Failure= 1 + Failure = 1, } -export class ActionResult { +export class ActionResult { private readonly message: string; private readonly resultType: ResultType; + private readonly value?: T; - private constructor(resultType: ResultType, message: string) { + private constructor(resultType: ResultType, message: string, value?: T) { this.resultType = resultType; this.message = message; + this.value = value; } - static success() { - return new ActionResult(ResultType.Success, " Succeeded "); + static success(value?: T): ActionResult { + return new ActionResult(ResultType.Success, "Succeeded", value); } - static failed() { - return new ActionResult(ResultType.Failure, " Failed "); + static failed(message = "Failed"): ActionResult { + return new ActionResult(ResultType.Failure, message); } - static cancelled() { - return new ActionResult(ResultType.Cancel, " Cancelled "); + static cancelled(message = "Cancelled"): ActionResult { + return new ActionResult(ResultType.Cancel, message); } - static stopped() { - return new ActionResult(ResultType.Cancel, " Stopped "); + static stopped(message = "Stopped"): ActionResult { + return new ActionResult(ResultType.Cancel, message); } - public getMessage() { + public getMessage(): string { return this.message; } - public getExitCode() { + public getExitCode(): number { return this.resultType.valueOf(); } - public isFailed() { + public isFailed(): boolean { return this.resultType === ResultType.Failure; } - public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T): T { + public isSuccess(): boolean { + return this.resultType === ResultType.Success; + } + + public isCancelled(): boolean { + return this.resultType === ResultType.Cancel; + } + + public match( + onSuccess: (value: T) => R, + onFailure: (message: string) => R, + onCancel: (message: string) => R + ): R { switch (this.resultType) { case ResultType.Success: - return onSuccess(); + return onSuccess(this.value!); + case ResultType.Failure: + return onFailure(this.message); + case ResultType.Cancel: + return onCancel(this.message); + } + } + + public getValue(): T { + if (!this.isSuccess()) { + throw new Error(`Cannot unwrap ${ResultType[this.resultType]} result: ${this.message}`); + } + return this.value!; + } + + public getValueOr(defaultValue: T): T { + return this.isSuccess() ? this.value! : defaultValue; + } + + public mapAll( + onSuccess: (value?: T) => R, + onFailure: () => R, + onCancel: () => R + ): R { + switch (this.resultType) { + case ResultType.Success: + return onSuccess(this.value); case ResultType.Failure: return onFailure(); case ResultType.Cancel: return onCancel(); } } -} +} \ No newline at end of file diff --git a/src/actions/api/validate.ts b/src/actions/api/validate.ts index 4af0ee70..5a15d058 100644 --- a/src/actions/api/validate.ts +++ b/src/actions/api/validate.ts @@ -1,11 +1,12 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { ApiValidatePrompts } from "../../prompts/api/validate.js"; -import { ValidationService } from "../../infrastructure/services/validation-service.js"; +import { UnallowedFeaturesResponse, ValidationService } from "../../infrastructure/services/validation-service.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { ResourceInput } from "../../types/file/resource-input.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; import { ResourceContext } from "../../types/resource-context.js"; +import { ValidationSummary } from "@apimatic/sdk"; export class ValidateAction { private readonly prompts: ApiValidatePrompts = new ApiValidatePrompts(); @@ -22,7 +23,7 @@ export class ValidateAction { public readonly execute = async ( resourcePath: ResourceInput, displayValidationSummary = true - ): Promise => { + ): Promise> => { return await withDirPath(async (tempDirectory) => { const resourceContext = new ResourceContext(tempDirectory); const specFileDirResult = await resourceContext.resolveTo(resourcePath); @@ -44,13 +45,26 @@ export class ValidateAction { } const validationSummary = validationSummaryResult.value; if (displayValidationSummary) { - this.prompts.displayValidationMessages(validationSummary); + if (this.hasValidationIssues(validationSummary.result.validation)) { + this.prompts.displayValidationSummary(validationSummary.result.validation); + } + if (this.hasValidationIssues(validationSummary.result.linting)) { + this.prompts.displayValidationSummary(validationSummary.result.linting); + } } - if (!validationSummary.success) { + if (!validationSummary.result.validation.isSuccess || !validationSummary.result.linting.isSuccess) { return ActionResult.failed(); } - - return ActionResult.success(); + return ActionResult.success(validationSummary.unallowedFeatures); }); }; + + private hasValidationIssues(summary: ValidationSummary): boolean { + return ( + summary.blocking.length > 0 || + summary.errors.length > 0 || + summary.warnings.length > 0 || + summary.information.length > 0 + ); + } } diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 22531921..422aebc2 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -16,6 +16,8 @@ import { FileDownloadService } from "../../infrastructure/services/file-download import { getLanguagesConfig } from "../../types/build/build.js"; import { FilePath } from "../../types/file/filePath.js"; import { SpecContext } from "../../types/spec-context.js"; +import { FeaturesToRemove, ValidationService } from "../../infrastructure/services/validation-service.js"; +import { FileName } from "../../types/file/fileName.js"; const defaultPort: number = 3000 as const; @@ -26,19 +28,25 @@ export class PortalQuickstartAction { private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); - private readonly buildFileUrl = new UrlPath(`https://github.com/apimatic/sample-docs-as-code-portal/archive/refs/heads/master.zip`); - private readonly defaultSpecUrl = new UrlPath(`https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json`); + private readonly buildFileUrl = new UrlPath( + `https://github.com/apimatic/sample-docs-as-code-portal/archive/refs/heads/master.zip` + ); + private readonly defaultSpecUrl = new UrlPath( + `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json` + ); private readonly repositoryFolderName = "sample-docs-as-code-portal-master/src" as const; + private readonly validationService: ValidationService; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { this.configDir = configDir; this.commandMetadata = commandMetadata; + this.validationService = new ValidationService(this.configDir); } public readonly execute = async (): Promise => { const storedAuth = await getAuthInfo(this.configDir.toString()); if (!storedAuth?.authKey) { - const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); + const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); if (loginResult.isFailed()) { return ActionResult.failed(); } @@ -98,6 +106,24 @@ export class PortalQuickstartAction { } } + const unallowed = validationResult.getValue(); + if (unallowed && (unallowed.Features?.length > 0 || unallowed.EndpointCount > unallowed.EndpointLimit)) { + const config: FeaturesToRemove = { + features: unallowed.Features.filter((name) => !!name), + endpointsToKeep: unallowed.EndpointLimit + }; + + const stripUnallowedFeaturesResult = await this.validationService.stripUnallowedFeatures(specPath, config); + if (stripUnallowedFeaturesResult.isErr()) { + this.prompts.splitSpecDetected(unallowed); + return ActionResult.failed(); + } else { + this.prompts.stripUnallowedFeaturesStep(unallowed); + const specContext = new SpecContext(tempDirectory); + specPath = await specContext.save(stripUnallowedFeaturesResult.value, new FileName("pruned-spec.zip")); + } + } + // Step 3/4 this.prompts.selectLanguagesStep(); const languages = await this.prompts.selectLanguagesPrompt(); @@ -163,9 +189,11 @@ export class PortalQuickstartAction { const result = await portalServeAction.execute(sourceDirectory, portalDirectory, defaultPort, true, false, () => { this.prompts.nextSteps(); }); + if (result.isFailed()) { return ActionResult.failed(); } + return ActionResult.success(); }); }; diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index b5c53760..13bb3e7b 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -16,6 +16,7 @@ import { Language } from "../../types/sdk/generate.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { ZipService } from "../../infrastructure/zip-service.js"; import { FileName } from "../../types/file/fileName.js"; +import { FeaturesToRemove, ValidationService } from "../../infrastructure/services/validation-service.js"; const defaultSpecUrl = new UrlPath( `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json` @@ -30,6 +31,7 @@ export class SdkQuickstartAction { private readonly fileService = new FileService(); private readonly launcherService = new LauncherService(); private readonly zipService = new ZipService(); + private readonly validationService = new ValidationService(this.configDir); constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} @@ -97,6 +99,24 @@ export class SdkQuickstartAction { } } + const unallowed = validationResult.getValue(); + if (unallowed && (unallowed.Features?.length > 0 || unallowed.EndpointCount > unallowed.EndpointLimit)) { + const config: FeaturesToRemove = { + features: unallowed.Features.filter((name) => !!name), + endpointsToKeep: unallowed.EndpointLimit + }; + + const stripUnallowedFeaturesResult = await this.validationService.stripUnallowedFeatures(specPath, config); + if (stripUnallowedFeaturesResult.isErr()) { + this.prompts.splitSpecDetected(unallowed); + return ActionResult.failed(); + } else { + this.prompts.stripUnallowedFeaturesStep(unallowed); + const specContext = new SpecContext(tempDirectory); + specPath = await specContext.save(stripUnallowedFeaturesResult.value, new FileName("pruned-spec.zip")); + } + } + // Step 3/4 this.prompts.selectLanguageStep(); diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 2277d275..387bf708 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -192,7 +192,7 @@ export class PortalService { const message = (error.result!.errors as Record)?.[""]?.[0]; return error.result!.title + "\n- " + message; } - return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; + return "An unexpected error occurred while generating the SDK, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; }; private parseBadRequestResponse(errorMessage: string | undefined): string { diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 0df814c2..3ec96ccb 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -1,8 +1,9 @@ +import { createReadStream } from "node:fs"; import fsExtra from "fs-extra"; import { ApiResponse, - ApiValidationExternalApisController, - ApiValidationSummary, + ApiValidationV2ExternalApisController, + ValidateApiResult, ContentType, FileWrapper, ApiError @@ -14,6 +15,25 @@ import { apiClientFactory } from "./api-client-factory.js"; import { err, ok, Result } from "neverthrow"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import FormData from "form-data"; +import { handleServiceError, ServiceError } from "../service-error.js"; +import axios from "axios"; +import { envInfo } from "../env-info.js"; +import { Buffer } from "node:buffer"; + +export enum RemovableFeature { + Merging = 'Merging', + Pagination = 'Pagination', + Webhooks = 'Webhooks', + Callbacks = 'Callbacks', + MultipleAuthSchemes = 'MultipleAuthSchemes', + Oauth2 = 'OAuth2', +} + +export interface FeaturesToRemove { + features?: RemovableFeature[]; + endpointsToKeep?: number; +} export interface ValidateViaFileParams { file: FilePath; @@ -21,6 +41,18 @@ export interface ValidateViaFileParams { authKey?: string | null; } +export interface UnallowedFeaturesResponse { + Features: RemovableFeature[]; + EndpointLimit: number; + EndpointCount: number; + IsSplitSpec: boolean; +} + +export interface ValidateApiResponse { + result: ValidateApiResult; + unallowedFeatures: UnallowedFeaturesResponse | null; +} + export class ValidationService { constructor(private readonly configDir: DirectoryPath) {} @@ -28,26 +60,76 @@ export class ValidationService { file, commandMetadata, authKey - }: ValidateViaFileParams): Promise> { + }: ValidateViaFileParams): Promise> { const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString()); const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); - const controller = new ApiValidationExternalApisController(client); + const controller = new ApiValidationV2ExternalApisController(client); try { const fileDescriptor = new FileWrapper(fsExtra.createReadStream(file.toString())); - //TODO: Update spec to include origin query parameter. - const validation: ApiResponse = await controller.validateApiViaFile( + + const validation: ApiResponse = await controller.validateApiViaFileV2( ContentType.EnumMultipartformdata, fileDescriptor ); - return ok(validation.result as ApiValidationSummary); + const headerValue = validation.headers?.["x-unallowed-features"]; + let unallowedFeatures: UnallowedFeaturesResponse | null = null; + + if (headerValue) { + const decodedJson = globalThis.Buffer.from(headerValue, "base64").toString("utf8"); + const parsed = JSON.parse(decodedJson); + + unallowedFeatures = parsed as UnallowedFeaturesResponse; + } + + return ok({ + result: validation.result as ValidateApiResult, + unallowedFeatures + }); } catch (error) { return err(await this.handleValidationErrors(error)); } } + public async stripUnallowedFeatures( + specPath: FilePath, + featuresToRemove: FeaturesToRemove, + authKey?: string | null + ): Promise> { + const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString()); + const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); + + const formData = new FormData(); + formData.append("file", createReadStream(specPath.toString())); + formData.append("featuresToRemove", JSON.stringify(featuresToRemove)); + + const baseURL = envInfo.getBaseUrl(); + + try { + const response = await axios({ + method: "POST", + url: `${baseURL}/api-features/strip`, + data: formData, + headers: { + ...formData.getHeaders(), + Authorization: authorizationHeader + }, + responseType: "stream", + validateStatus: () => true + }); + + if (response.status >= 400) { + return err(await this.parseErrorResponse(response)); + } + + return ok(response.data); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { const key = overrideAuthKey || authInfo?.authKey; return `X-Auth-Key ${key ?? ""}`; @@ -57,19 +139,48 @@ export class ValidationService { if (error instanceof ApiError) { const apiError = error as ApiError; - if (apiError.statusCode === 400) { - return "Your API Definition is invalid. Please fix the issues and try again."; - } else if (apiError.statusCode === 401) { - return "You are not authorized to perform this action. Please run 'auth:login' or provide a valid auth key."; - } else if (apiError.statusCode === 403) { - return "You do not have permission to perform this action."; - } else if (apiError.statusCode === 500) { - return "An unexpected error occurred validating the API specification, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; + switch (apiError.statusCode) { + case 400: + return "Your API Definition is invalid. Please fix the issues and try again."; + case 401: + return "You are not authorized to perform this action. Please run 'auth:login' or provide a valid auth key."; + case 403: + return "You do not have permission to perform this action."; + case 500: + return "An unexpected error occurred validating the API specification, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; + default: + return `Error ${apiError.statusCode}: An error occurred during validation.`; } - - return `Error ${apiError.statusCode}: An error occurred during validation.`; } return "Unexpected error occurred while validating API specification."; } + + private async parseErrorResponse(response: any): Promise { + const chunks: Buffer[] = []; + for await (const chunk of response.data) { + chunks.push(Buffer.from(chunk)); + } + const errorBody = Buffer.concat(chunks).toString('utf-8'); + + let errorMessage = `Error ${response.status}: Failed to strip unallowed features.`; + + try { + const errorData = JSON.parse(errorBody); + if (errorData.errors?.summary?.[0]) { + errorMessage = errorData.errors.summary[0]; + } else if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.title) { + errorMessage = errorData.title; + } + } catch { + errorMessage = errorBody || errorMessage; + } + + return { + message: errorMessage, + statusCode: response.status + } as unknown as ServiceError; + } } diff --git a/src/prompts/api/validate.ts b/src/prompts/api/validate.ts index e432eb8f..3fc785c5 100644 --- a/src/prompts/api/validate.ts +++ b/src/prompts/api/validate.ts @@ -2,14 +2,15 @@ import { log } from "@clack/prompts"; import { replaceHTML } from "../../utils/utils.js"; import { ValidationMessages } from "../../types/utils.js"; import { Result } from "neverthrow"; -import { ApiValidationSummary } from "@apimatic/sdk"; +import { ValidationEntry, ValidationSummary } from "@apimatic/sdk"; import { ServiceError } from "../../infrastructure/service-error.js"; import { FilePath } from "../../types/file/filePath.js"; import { format as f } from "../format.js"; import { withSpinner } from "../prompt.js"; +import { ValidateApiResponse } from "../../infrastructure/services/validation-service.js"; export class ApiValidatePrompts { - public async validateApi(fn: Promise>) { + public async validateApi(fn: Promise>) { return withSpinner("Validating API", "API validation completed", "API validation failed", fn); } @@ -34,6 +35,50 @@ export class ApiValidatePrompts { } } + formatValidationEntry(entry: ValidationEntry): string { + let formatted = replaceHTML(entry.message); + + if (entry.fileReference && entry.lineInfo) { + formatted += ` [${entry.fileReference}:${entry.lineInfo.startLineNumber}:${entry.lineInfo.startLinePosition}]`; + } + + if (entry.jsonReferencePath) { + formatted += ` (${entry.jsonReferencePath})`; + } + + return formatted; + } + + public displayValidationSummary(summary: ValidationSummary): void { + if (summary.blocking.length > 0) { + log.error("Blocking"); + for (const entry of summary.blocking) { + log.message(this.formatValidationEntry(entry)); + } + } + + if (summary.errors.length > 0) { + log.error("Errors"); + for (const entry of summary.errors) { + log.message(this.formatValidationEntry(entry)); + } + } + + if (summary.warnings.length > 0) { + log.warning("Warnings"); + for (const entry of summary.warnings) { + log.message(this.formatValidationEntry(entry)); + } + } + + if (summary.information.length > 0) { + log.info("Information"); + for (const entry of summary.information) { + log.message(this.formatValidationEntry(entry)); + } + } + } + logValidationError(error: string): void { log.error(error); } diff --git a/src/prompts/format.ts b/src/prompts/format.ts index a5674d71..9769203c 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -35,15 +35,16 @@ export const format = { export function intro(text: string) { i(format.intro(` ${text} `)); } - -export function outro(result: ActionResult) { +export function outro(result: ActionResult) { const exitCode = result.getExitCode(); const message = result.getMessage(); + const outroMessage = result.mapAll( () => format.outroSuccess(message), () => format.outroFailure(message), () => format.outroCancelled(message) ); + o(outroMessage); process.exitCode = exitCode; } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 0899d741..aa7e6280 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -9,6 +9,7 @@ import { Directory } from "../../types/file/directory.js"; import { createResourceInputFromInput, ResourceInput } from "../../types/file/resource-input.js"; import { FileDownloadResponse } from "../../infrastructure/services/file-download-service.js"; import { noteWrapped, withSpinner } from "../prompt.js"; +import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -71,6 +72,50 @@ export class PortalQuickstartPrompts { log.info(`Step 2 of 4: Validate and Lint your OpenAPI Definition`); } + public splitSpecDetected(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); + + let endpointMessage = ""; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + endpointMessage = `\nEndpoint limit exceeded: ${unallowed.EndpointCount} endpoints found, but your plan allows ${unallowed.EndpointLimit}\n`; + } + + const message = [ + "Your API Specification includes components not available on your current subscription plan:", + "", + featuresList, + endpointMessage, + "To continue:", + "- Remove these components from your API Specification and re-run this command.", + "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", + "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + ].join("\n"); + + log.info(message); + } + + public stripUnallowedFeaturesStep(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); + + let endpointMessage = ""; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + const endpointsToRemove = unallowed.EndpointCount - unallowed.EndpointLimit; + endpointMessage = `\n${endpointsToRemove} endpoint(s) will be removed from your spec\n`; + } + + const message = [ + "Your API Specification includes components not available on your current subscription plan.", + "We'll automatically remove these components before proceeding:", + featuresList, + endpointMessage, + "", + "You won't see these components in the generated SDKs or documentation.", + "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + ].join("\n"); + + log.info(message); + } + public selectLanguagesStep() { log.info(`Step 3 of 4: Select programming languages`); } @@ -89,7 +134,7 @@ export class PortalQuickstartPrompts { { label: "Go", value: "go" } ], initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"], - required: false, + required: false })) as string[]; if (isCancel(languages)) { diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index 077b4531..5aaedb62 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -10,11 +10,11 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { Directory } from "../../types/file/directory.js"; import { Language } from "../../types/sdk/generate.js"; +import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; const sdkCustomizationUrl = "https://docs.apimatic.io/generate-sdks/codegen-settings/codegen-settings-overview/"; -const defaultSrcDirectoryPath = process.cwd(); export class SdkQuickstartPrompts { public importSpecStep() { @@ -42,6 +42,50 @@ export class SdkQuickstartPrompts { return createResourceInputFromInput(spec); } + public splitSpecDetected(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); + + let endpointMessage = ""; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + endpointMessage = `\nEndpoint limit exceeded: ${unallowed.EndpointCount} endpoints found, but your plan allows ${unallowed.EndpointLimit}\n`; + } + + const message = [ + "Your API Specification includes components not available on your current subscription plan:", + "", + featuresList, + endpointMessage, + "To continue:", + "- Remove these components from your API Specification and re-run this command.", + "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", + "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + ].join("\n"); + + log.info(message); + } + + public stripUnallowedFeaturesStep(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); + + let endpointMessage = ""; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + const endpointsToRemove = unallowed.EndpointCount - unallowed.EndpointLimit; + endpointMessage = `\n${endpointsToRemove} endpoint(s) will be removed from your spec\n`; + } + + const message = [ + "Your API Specification includes components not available on your current subscription plan.", + "We'll automatically remove these components before proceeding:", + featuresList, + endpointMessage, + "", + "You won't see these components in the generated SDKs or documentation.", + "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + ].join("\n"); + + log.info(message); + } + public specFileDoesNotExist() { log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); } @@ -179,7 +223,9 @@ export class SdkQuickstartPrompts { } public nextSteps(language: Language, specDirectory: DirectoryPath): void { - const specDirectoryFlag = !specDirectory.isEqual(DirectoryPath.default) ? `${f.flag("spec", specDirectory.toString())} `: ""; + const specDirectoryFlag = !specDirectory.isEqual(DirectoryPath.default) + ? `${f.flag("spec", specDirectory.toString())} ` + : ""; const message = `Run the command '${f.cmdAlt("apimatic", "sdk", "generate")} ${specDirectoryFlag}${f.flag("language", language)}' to regenerate your SDK. From 8dcb6a7467bd7569469b3f6dc1d76209bb7b5619 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:26:53 +0500 Subject: [PATCH 3/7] docs(readme): add webhooks and callbacks flags for toc command (#219) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 4f2f022b..6d9c23db 100644 --- a/README.md +++ b/README.md @@ -349,16 +349,21 @@ Generate a Table of Contents (TOC) file for your API documentation portal ``` USAGE $ apimatic portal toc new [-d ] [-i ] [-f] [--expand-endpoints] [--expand-models] + [--expand-webhooks] [--expand-callbacks] FLAGS -d, --destination= [default: /src/content] path where the toc.yml will be generated. -f, --force overwrite changes without asking for user consent. -i, --input= [default: ./] path to the parent directory containing the 'src' directory, which includes API specifications and configuration files. + --expand-callbacks include individual entries for each callback in the generated 'toc.yml'. Requires a valid + API specification in the working directory. --expand-endpoints include individual entries for each endpoint in the generated 'toc.yml'. Requires a valid API specification in the working directory. --expand-models include individual entries for each model in the generated 'toc.yml'. Requires a valid API specification in the working directory. + --expand-webhooks include individual entries for each webhook in the generated 'toc.yml'. Requires a valid + API specification in the working directory. DESCRIPTION Generate a Table of Contents (TOC) file for your API documentation portal From e0aa367b7cf34e13973ec3c667e6c0b468ca18e8 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 10 Nov 2025 10:36:27 +0500 Subject: [PATCH 4/7] refactor --- src/actions/portal/quickstart.ts | 29 +++----- src/actions/sdk/quickstart.ts | 73 +++++++++---------- .../services/validation-service.ts | 63 ++++++++++++++++ 3 files changed, 109 insertions(+), 56 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 422aebc2..d66eeb6b 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -16,8 +16,8 @@ import { FileDownloadService } from "../../infrastructure/services/file-download import { getLanguagesConfig } from "../../types/build/build.js"; import { FilePath } from "../../types/file/filePath.js"; import { SpecContext } from "../../types/spec-context.js"; -import { FeaturesToRemove, ValidationService } from "../../infrastructure/services/validation-service.js"; -import { FileName } from "../../types/file/fileName.js"; +import { ValidationService } from "../../infrastructure/services/validation-service.js"; + const defaultPort: number = 3000 as const; @@ -107,22 +107,17 @@ export class PortalQuickstartAction { } const unallowed = validationResult.getValue(); - if (unallowed && (unallowed.Features?.length > 0 || unallowed.EndpointCount > unallowed.EndpointLimit)) { - const config: FeaturesToRemove = { - features: unallowed.Features.filter((name) => !!name), - endpointsToKeep: unallowed.EndpointLimit - }; - - const stripUnallowedFeaturesResult = await this.validationService.stripUnallowedFeatures(specPath, config); - if (stripUnallowedFeaturesResult.isErr()) { - this.prompts.splitSpecDetected(unallowed); - return ActionResult.failed(); - } else { - this.prompts.stripUnallowedFeaturesStep(unallowed); - const specContext = new SpecContext(tempDirectory); - specPath = await specContext.save(stripUnallowedFeaturesResult.value, new FileName("pruned-spec.zip")); - } + const featureResult = await this.validationService.processUnallowedFeatures(specPath, unallowed, tempDirectory); + + if (!featureResult.success && featureResult.unallowedInfo) { + this.prompts.splitSpecDetected(featureResult.unallowedInfo); + return ActionResult.failed(); + } + + if (featureResult.featuresWereStripped && featureResult.unallowedInfo) { + this.prompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); } + specPath = featureResult.updatedSpecPath ?? specPath; // Step 3/4 this.prompts.selectLanguagesStep(); diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 13bb3e7b..2f77edce 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -1,22 +1,22 @@ -import { SdkQuickstartPrompts } from "../../prompts/sdk/quickstart.js"; -import { ActionResult } from "../action-result.js"; -import { UrlPath } from "../../types/file/urlPath.js"; -import { LoginAction } from "../auth/login.js"; -import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { getAuthInfo } from "../../client-utils/auth-manager.js"; -import { withDirPath } from "../../infrastructure/tmp-extensions.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { SpecContext } from "../../types/spec-context.js"; -import { ValidateAction } from "../api/validate.js"; -import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; -import { FileService } from "../../infrastructure/file-service.js"; -import { GenerateAction } from "./generate.js"; -import { Language } from "../../types/sdk/generate.js"; -import { LauncherService } from "../../infrastructure/launcher-service.js"; -import { ZipService } from "../../infrastructure/zip-service.js"; -import { FileName } from "../../types/file/fileName.js"; -import { FeaturesToRemove, ValidationService } from "../../infrastructure/services/validation-service.js"; +import { SdkQuickstartPrompts } from '../../prompts/sdk/quickstart.js'; +import { ActionResult } from '../action-result.js'; +import { UrlPath } from '../../types/file/urlPath.js'; +import { LoginAction } from '../auth/login.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { getAuthInfo } from '../../client-utils/auth-manager.js'; +import { withDirPath } from '../../infrastructure/tmp-extensions.js'; +import { FilePath } from '../../types/file/filePath.js'; +import { SpecContext } from '../../types/spec-context.js'; +import { ValidateAction } from '../api/validate.js'; +import { FileDownloadService } from '../../infrastructure/services/file-download-service.js'; +import { FileService } from '../../infrastructure/file-service.js'; +import { GenerateAction } from './generate.js'; +import { Language } from '../../types/sdk/generate.js'; +import { LauncherService } from '../../infrastructure/launcher-service.js'; +import { ZipService } from '../../infrastructure/zip-service.js'; +import { FileName } from '../../types/file/fileName.js'; +import { ValidationService } from '../../infrastructure/services/validation-service.js'; const defaultSpecUrl = new UrlPath( `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json` @@ -100,22 +100,17 @@ export class SdkQuickstartAction { } const unallowed = validationResult.getValue(); - if (unallowed && (unallowed.Features?.length > 0 || unallowed.EndpointCount > unallowed.EndpointLimit)) { - const config: FeaturesToRemove = { - features: unallowed.Features.filter((name) => !!name), - endpointsToKeep: unallowed.EndpointLimit - }; - - const stripUnallowedFeaturesResult = await this.validationService.stripUnallowedFeatures(specPath, config); - if (stripUnallowedFeaturesResult.isErr()) { - this.prompts.splitSpecDetected(unallowed); - return ActionResult.failed(); - } else { - this.prompts.stripUnallowedFeaturesStep(unallowed); - const specContext = new SpecContext(tempDirectory); - specPath = await specContext.save(stripUnallowedFeaturesResult.value, new FileName("pruned-spec.zip")); - } + const featureResult = await this.validationService.processUnallowedFeatures(specPath, unallowed, tempDirectory); + + if (!featureResult.success && featureResult.unallowedInfo) { + this.prompts.splitSpecDetected(featureResult.unallowedInfo); + return ActionResult.failed(); + } + + if (featureResult.featuresWereStripped && featureResult.unallowedInfo) { + this.prompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); } + specPath = featureResult.updatedSpecPath ?? specPath; // Step 3/4 this.prompts.selectLanguageStep(); @@ -158,7 +153,7 @@ export class SdkQuickstartAction { this.prompts.serviceError(apimaticMetaFile.error); return ActionResult.failed(); } - const tempSpecDirectory = tempDirectory.join("spec"); + const tempSpecDirectory = tempDirectory.join('spec'); await this.fileService.createDirectoryIfNotExists(tempSpecDirectory); const metadataFilePath = new FilePath(tempSpecDirectory, apimaticMetaFile.value.filename); await this.fileService.writeFile(metadataFilePath, apimaticMetaFile.value.stream); @@ -169,14 +164,14 @@ export class SdkQuickstartAction { await this.fileService.copyToDir(specPath, tempSpecDirectory); } - const sourceDirectory = inputDirectory.join("src"); - const specDirectory = sourceDirectory.join("spec"); + const sourceDirectory = inputDirectory.join('src'); + const specDirectory = sourceDirectory.join('spec'); await this.fileService.copyDirectoryContents(tempSpecDirectory, specDirectory); const srcDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); this.prompts.printDirectoryStructure(inputDirectory, srcDirectoryStructure); - const sdkDirectory = inputDirectory.join("sdk"); + const sdkDirectory = inputDirectory.join('sdk'); const sdkGenerateAction = new GenerateAction(this.configDir, this.commandMetadata); const result = await sdkGenerateAction.execute(specDirectory, sdkDirectory, language as Language, true, false); if (result.isFailed()) { @@ -184,7 +179,7 @@ export class SdkQuickstartAction { } const languageDirectory = sdkDirectory.join(language); - const readmeFilePath = new FilePath(languageDirectory, new FileName("README.md")); + const readmeFilePath = new FilePath(languageDirectory, new FileName('README.md')); if (await this.launcherService.openFolderInIde(languageDirectory, readmeFilePath)) { this.prompts.sdkOpenedInEditor(); } diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 3ec96ccb..95cd5c1b 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -20,6 +20,8 @@ import { handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; +import { FileName } from "../../types/file/fileName.js"; +import { SpecContext } from "../../types/spec-context.js"; export enum RemovableFeature { Merging = 'Merging', @@ -53,6 +55,15 @@ export interface ValidateApiResponse { unallowedFeatures: UnallowedFeaturesResponse | null; } +export interface ProcessUnallowedFeaturesResult { + success: boolean; + updatedSpecPath?: FilePath; + splitSpecDetected?: boolean; + featuresWereStripped?: boolean; + unallowedInfo?: UnallowedFeaturesResponse; +} + + export class ValidationService { constructor(private readonly configDir: DirectoryPath) {} @@ -130,6 +141,58 @@ export class ValidationService { } } + public async processUnallowedFeatures( + specPath: FilePath, + unallowed: UnallowedFeaturesResponse | null, + tempDirectory: DirectoryPath, + authKey?: string | null + ): Promise { + if (!unallowed || (unallowed.Features?.length === 0 && unallowed.EndpointCount <= unallowed.EndpointLimit)) { + return { + success: true, + updatedSpecPath: specPath, + featuresWereStripped: false + }; + } + + if (unallowed.IsSplitSpec) { + return { + success: false, + splitSpecDetected: true, + unallowedInfo: unallowed + }; + } + + const config: FeaturesToRemove = { + features: unallowed.Features.filter((name) => !!name), + endpointsToKeep: unallowed.EndpointLimit + }; + + const stripResult = await this.stripUnallowedFeatures(specPath, config, authKey); + + if (stripResult.isErr()) { + return { + success: false, + splitSpecDetected: true, + unallowedInfo: unallowed + }; + } + + const specContext = new SpecContext(tempDirectory); + const updatedSpecPath = await specContext.save( + stripResult.value, + new FileName("pruned-spec.zip") + ); + + return { + success: true, + updatedSpecPath, + featuresWereStripped: true, + unallowedInfo: unallowed + }; + } + + private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { const key = overrideAuthKey || authInfo?.authKey; return `X-Auth-Key ${key ?? ""}`; From c985ed7c2bbb0b34fa15f1b82daf625fffd166e4 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 10 Nov 2025 10:58:57 +0500 Subject: [PATCH 5/7] merge commit --- src/infrastructure/services/validation-service.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 0d65adcf..95cd5c1b 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -95,20 +95,6 @@ export class ValidationService { unallowedFeatures = parsed as UnallowedFeaturesResponse; } - return ok({ - result: validation.result as ValidateApiResult, - unallowedFeatures - }); - const headerValue = validation.headers?.["x-unallowed-features"]; - let unallowedFeatures: UnallowedFeaturesResponse | null = null; - - if (headerValue) { - const decodedJson = globalThis.Buffer.from(headerValue, "base64").toString("utf8"); - const parsed = JSON.parse(decodedJson); - - unallowedFeatures = parsed as UnallowedFeaturesResponse; - } - return ok({ result: validation.result as ValidateApiResult, unallowedFeatures From a6a523cae2e0cb5ac1147054c3958eef1820fa15 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 10 Nov 2025 11:01:05 +0500 Subject: [PATCH 6/7] build error --- src/actions/sdk/quickstart.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 71163cfa..2f77edce 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -32,7 +32,6 @@ export class SdkQuickstartAction { private readonly launcherService = new LauncherService(); private readonly zipService = new ZipService(); private readonly validationService = new ValidationService(this.configDir); - private readonly validationService = new ValidationService(this.configDir); constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} From c6b2a78b108a11a39fb59660f3277de31eeae5ac Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 10 Nov 2025 11:06:06 +0500 Subject: [PATCH 7/7] remove prompt duplication --- src/actions/portal/quickstart.ts | 53 ++++++++++++++++---------------- src/actions/sdk/quickstart.ts | 6 ++-- src/prompts/portal/quickstart.ts | 44 -------------------------- src/prompts/sdk/quickstart.ts | 45 --------------------------- src/prompts/strip/strip.ts | 48 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 117 deletions(-) create mode 100644 src/prompts/strip/strip.ts diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index d66eeb6b..ea9560d5 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -1,29 +1,30 @@ -import { getAuthInfo } from "../../client-utils/auth-manager.js"; -import { FileService } from "../../infrastructure/file-service.js"; -import { withDirPath } from "../../infrastructure/tmp-extensions.js"; -import { ZipService } from "../../infrastructure/zip-service.js"; -import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { UrlPath } from "../../types/file/urlPath.js"; -import { LoginAction } from "../auth/login.js"; -import { ActionResult } from "../action-result.js"; -import { PortalServeAction } from "./serve.js"; -import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { ValidateAction } from "../api/validate.js"; -import { BuildContext } from "../../types/build-context.js"; -import { TempContext } from "../../types/temp-context.js"; -import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; -import { getLanguagesConfig } from "../../types/build/build.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { SpecContext } from "../../types/spec-context.js"; -import { ValidationService } from "../../infrastructure/services/validation-service.js"; - +import { getAuthInfo } from '../../client-utils/auth-manager.js'; +import { FileService } from '../../infrastructure/file-service.js'; +import { withDirPath } from '../../infrastructure/tmp-extensions.js'; +import { ZipService } from '../../infrastructure/zip-service.js'; +import { PortalQuickstartPrompts } from '../../prompts/portal/quickstart.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { UrlPath } from '../../types/file/urlPath.js'; +import { LoginAction } from '../auth/login.js'; +import { ActionResult } from '../action-result.js'; +import { PortalServeAction } from './serve.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { ValidateAction } from '../api/validate.js'; +import { BuildContext } from '../../types/build-context.js'; +import { TempContext } from '../../types/temp-context.js'; +import { FileDownloadService } from '../../infrastructure/services/file-download-service.js'; +import { getLanguagesConfig } from '../../types/build/build.js'; +import { FilePath } from '../../types/file/filePath.js'; +import { SpecContext } from '../../types/spec-context.js'; +import { ValidationService } from '../../infrastructure/services/validation-service.js'; +import { stripPrompts } from '../../prompts/strip/strip.js'; const defaultPort: number = 3000 as const; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); + private readonly stripSpecPrompts = new stripPrompts(); private readonly fileService: FileService = new FileService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; @@ -34,7 +35,7 @@ export class PortalQuickstartAction { private readonly defaultSpecUrl = new UrlPath( `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json` ); - private readonly repositoryFolderName = "sample-docs-as-code-portal-master/src" as const; + private readonly repositoryFolderName = 'sample-docs-as-code-portal-master/src' as const; private readonly validationService: ValidationService; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { @@ -110,12 +111,12 @@ export class PortalQuickstartAction { const featureResult = await this.validationService.processUnallowedFeatures(specPath, unallowed, tempDirectory); if (!featureResult.success && featureResult.unallowedInfo) { - this.prompts.splitSpecDetected(featureResult.unallowedInfo); + this.stripSpecPrompts.splitSpecDetected(featureResult.unallowedInfo); return ActionResult.failed(); } if (featureResult.featuresWereStripped && featureResult.unallowedInfo) { - this.prompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); + this.stripSpecPrompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); } specPath = featureResult.updatedSpecPath ?? specPath; @@ -169,17 +170,17 @@ export class PortalQuickstartAction { buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); await tempBuildContext.updateBuildFileContents(buildFile); - const sourceDirectory = inputDirectory.join("src"); + const sourceDirectory = inputDirectory.join('src'); await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); - const specDirectory = sourceDirectory.join("spec"); + const specDirectory = sourceDirectory.join('spec'); const specContext = new SpecContext(specDirectory); await specContext.replaceDefaultSpec(specPath); const buildDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); this.prompts.printDirectoryStructure(inputDirectory, buildDirectoryStructure); - const portalDirectory = inputDirectory.join("portal"); + const portalDirectory = inputDirectory.join('portal'); const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); const result = await portalServeAction.execute(sourceDirectory, portalDirectory, defaultPort, true, false, () => { this.prompts.nextSteps(); diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 2f77edce..45fba478 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -17,6 +17,7 @@ import { LauncherService } from '../../infrastructure/launcher-service.js'; import { ZipService } from '../../infrastructure/zip-service.js'; import { FileName } from '../../types/file/fileName.js'; import { ValidationService } from '../../infrastructure/services/validation-service.js'; +import { stripPrompts } from '../../prompts/strip/strip.js'; const defaultSpecUrl = new UrlPath( `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/openapi.json` @@ -27,6 +28,7 @@ const metadataFileUrl = new UrlPath( export class SdkQuickstartAction { private readonly prompts = new SdkQuickstartPrompts(); + private readonly stripSpecPrompts = new stripPrompts(); private readonly fileDownloadService = new FileDownloadService(); private readonly fileService = new FileService(); private readonly launcherService = new LauncherService(); @@ -103,12 +105,12 @@ export class SdkQuickstartAction { const featureResult = await this.validationService.processUnallowedFeatures(specPath, unallowed, tempDirectory); if (!featureResult.success && featureResult.unallowedInfo) { - this.prompts.splitSpecDetected(featureResult.unallowedInfo); + this.stripSpecPrompts.splitSpecDetected(featureResult.unallowedInfo); return ActionResult.failed(); } if (featureResult.featuresWereStripped && featureResult.unallowedInfo) { - this.prompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); + this.stripSpecPrompts.stripUnallowedFeaturesStep(featureResult.unallowedInfo); } specPath = featureResult.updatedSpecPath ?? specPath; diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index aa7e6280..66ce8401 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -72,50 +72,6 @@ export class PortalQuickstartPrompts { log.info(`Step 2 of 4: Validate and Lint your OpenAPI Definition`); } - public splitSpecDetected(unallowed: UnallowedFeaturesResponse): void { - const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); - - let endpointMessage = ""; - if (unallowed.EndpointLimit < unallowed.EndpointCount) { - endpointMessage = `\nEndpoint limit exceeded: ${unallowed.EndpointCount} endpoints found, but your plan allows ${unallowed.EndpointLimit}\n`; - } - - const message = [ - "Your API Specification includes components not available on your current subscription plan:", - "", - featuresList, - endpointMessage, - "To continue:", - "- Remove these components from your API Specification and re-run this command.", - "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", - "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" - ].join("\n"); - - log.info(message); - } - - public stripUnallowedFeaturesStep(unallowed: UnallowedFeaturesResponse): void { - const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); - - let endpointMessage = ""; - if (unallowed.EndpointLimit < unallowed.EndpointCount) { - const endpointsToRemove = unallowed.EndpointCount - unallowed.EndpointLimit; - endpointMessage = `\n${endpointsToRemove} endpoint(s) will be removed from your spec\n`; - } - - const message = [ - "Your API Specification includes components not available on your current subscription plan.", - "We'll automatically remove these components before proceeding:", - featuresList, - endpointMessage, - "", - "You won't see these components in the generated SDKs or documentation.", - "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" - ].join("\n"); - - log.info(message); - } - public selectLanguagesStep() { log.info(`Step 3 of 4: Select programming languages`); } diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index 5aaedb62..ac0a27dc 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -10,7 +10,6 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { Directory } from "../../types/file/directory.js"; import { Language } from "../../types/sdk/generate.js"; -import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -42,50 +41,6 @@ export class SdkQuickstartPrompts { return createResourceInputFromInput(spec); } - public splitSpecDetected(unallowed: UnallowedFeaturesResponse): void { - const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); - - let endpointMessage = ""; - if (unallowed.EndpointLimit < unallowed.EndpointCount) { - endpointMessage = `\nEndpoint limit exceeded: ${unallowed.EndpointCount} endpoints found, but your plan allows ${unallowed.EndpointLimit}\n`; - } - - const message = [ - "Your API Specification includes components not available on your current subscription plan:", - "", - featuresList, - endpointMessage, - "To continue:", - "- Remove these components from your API Specification and re-run this command.", - "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", - "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" - ].join("\n"); - - log.info(message); - } - - public stripUnallowedFeaturesStep(unallowed: UnallowedFeaturesResponse): void { - const featuresList = unallowed.Features.map((f) => ` • ${f}`).join("\n"); - - let endpointMessage = ""; - if (unallowed.EndpointLimit < unallowed.EndpointCount) { - const endpointsToRemove = unallowed.EndpointCount - unallowed.EndpointLimit; - endpointMessage = `\n${endpointsToRemove} endpoint(s) will be removed from your spec\n`; - } - - const message = [ - "Your API Specification includes components not available on your current subscription plan.", - "We'll automatically remove these components before proceeding:", - featuresList, - endpointMessage, - "", - "You won't see these components in the generated SDKs or documentation.", - "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" - ].join("\n"); - - log.info(message); - } - public specFileDoesNotExist() { log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); } diff --git a/src/prompts/strip/strip.ts b/src/prompts/strip/strip.ts new file mode 100644 index 00000000..4bec935b --- /dev/null +++ b/src/prompts/strip/strip.ts @@ -0,0 +1,48 @@ +import { log } from "@clack/prompts"; +import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; + +export class stripPrompts { + public splitSpecDetected(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join('\n'); + + let endpointMessage = ''; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + endpointMessage = `\nEndpoint limit exceeded: ${unallowed.EndpointCount} endpoints found, but your plan allows ${unallowed.EndpointLimit}\n`; + } + + const message = [ + 'Your API Specification includes components not available on your current subscription plan:', + '', + featuresList, + endpointMessage, + 'To continue:', + '- Remove these components from your API Specification and re-run this command.', + '- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.', + '- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing' + ].join('\n'); + + log.info(message); + } + + public stripUnallowedFeaturesStep(unallowed: UnallowedFeaturesResponse): void { + const featuresList = unallowed.Features.map((f) => ` • ${f}`).join('\n'); + + let endpointMessage = ''; + if (unallowed.EndpointLimit < unallowed.EndpointCount) { + const endpointsToRemove = unallowed.EndpointCount - unallowed.EndpointLimit; + endpointMessage = `\n${endpointsToRemove} endpoint(s) will be removed from your spec\n`; + } + + const message = [ + 'Your API Specification includes components not available on your current subscription plan.', + "We'll automatically remove these components before proceeding:", + featuresList, + endpointMessage, + '', + "You won't see these components in the generated SDKs or documentation.", + 'Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing' + ].join('\n'); + + log.info(message); + } +}