diff --git a/.ai/skills/service.md b/.ai/skills/service.md index 91359365..33058793 100644 --- a/.ai/skills/service.md +++ b/.ai/skills/service.md @@ -34,7 +34,9 @@ Services live at `src/infrastructure/services/` and are the only layer that make - Instantiate controller per method call: `new {ControllerName}(client)` inside the method. - `apiClientFactory.createApiClient(authHeader, shell)` provides the configured client. -- For async/polling SDK methods, don't hand-roll the poll loop — reuse `pollUntilCompleted()` in `portal-service.ts`, passing the poll interval as its first argument. Add a `static readonly` instance to `GenerationStatusEndpoint` for the new `{basePath}/{requestId}/status` endpoint and pass it to `ApiService.getGenerationStatus()`. Only supply a `ValidationErrorFormatter` when the endpoint needs custom validation wording (see `formatSdkValidationError`). +- For async/polling SDK methods, don't hand-roll the poll loop — reuse `pollUntilCompleted()` from `src/infrastructure/generation-status-poller.ts`, passing `{ pollIntervalMs, fetchStatus, timeout }`. Every pollable flow must pass a `timeout: { budgetMs, label }`; without one a stuck generation sits on the spinner forever. The budget alone is not a limit — it is only read between polls, so the `axiosInstance` behind `fetchStatus` also needs a `timeout`, or a poll that connects and never answers outlives the budget and hangs the CLI. The `label` opens the timeout message, so name the flow as the user knows it (`"Portal generation"`, `"SDK generation"`). Take the budget from a `generationTimeoutMs` constructor parameter so tests can shrink it. Only supply a `formatValidationError` when the endpoint needs custom validation wording (see `formatSdkValidationError`). +- Give each pollable endpoint its own private `get{Flow}GenerationStatus()` on the service that owns the flow, with the `{basePath}/{requestId}/status` path written out in it. Generation flows are independent and free to diverge, so the status fetch is deliberately not shared and takes no endpoint parameter, even where two flows currently read identically — resist factoring the bodies back together. A finished generation arrives as a `302` to the download location, not a `Completed` status body, so each method needs `maxRedirects: 0` and its own `302` mapping. +- An SDK-controller service that polls therefore also carries the axios-auth plumbing (`axiosInstance`, `apiBaseUrl`): the status endpoints are read over raw axios because a generated controller cannot surface the `302`. `portal-service.ts` is both variants at once for that reason. ### Axios-auth variant rules @@ -72,7 +74,8 @@ Services live at `src/infrastructure/services/` and are the only layer that make | Pattern | File | |---|---| | SDK controller + async polling | `src/infrastructure/services/portal-service.ts` | -| Pollable generation endpoints | `src/types/api/generation-status-endpoint.ts` | +| Per-endpoint generation status fetch | `src/infrastructure/services/portal-service.ts`, `src/infrastructure/services/plugin-service.ts` | +| Shared generation poll loop | `src/infrastructure/generation-status-poller.ts` | | SDK controller + FormData | `src/infrastructure/services/validation-service.ts` | | Raw axios with auth + axiosInstance | `src/infrastructure/services/api-service.ts` | | Raw axios with different base URL | `src/infrastructure/services/auth-service.ts` | diff --git a/README.md b/README.md index 08fef0d8..01a907a2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ USAGE * [`apimatic auth status`](#apimatic-auth-status) * [`apimatic autocomplete [SHELL]`](#apimatic-autocomplete-shell) * [`apimatic help [COMMAND]`](#apimatic-help-command) +* [`apimatic plugin generate`](#apimatic-plugin-generate) * [`apimatic portal copilot`](#apimatic-portal-copilot) * [`apimatic portal generate`](#apimatic-portal-generate) * [`apimatic portal recipe new`](#apimatic-portal-recipe-new) @@ -222,6 +223,36 @@ DESCRIPTION _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/main/src/commands/help.ts)_ +## `apimatic plugin generate` + +Generate a Claude Code context plugin for your published SDKs. + +``` +USAGE + $ apimatic plugin generate [--zip] [-i ] [-d ] [-f] [-k ] + +FLAGS + -d, --destination= [default: /plugin] path where the plugin 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. + -k, --auth-key= override current authentication state with an authentication key. + --zip download the generated plugin as a .zip archive + +DESCRIPTION + Generate a Claude Code context plugin for your published SDKs. + + Generate a context plugin that teaches an AI coding assistant how to use your SDKs. Requires an input directory + containing a `src` directory with a `plugin-config.json`. + +EXAMPLES + apimatic plugin generate + + apimatic plugin generate --input="./" --destination="./plugin" +``` + +_See code: [src/commands/plugin/generate.ts](https://github.com/apimatic/apimatic-cli/blob/beta/src/commands/plugin/generate.ts)_ + ## `apimatic portal copilot` Configure API Copilot for your API Documentation portal diff --git a/package.json b/package.json index 288228a9..1b821ab1 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,9 @@ "auth": { "description": "Login using your APIMatic credentials, or view your authentication status." }, + "plugin": { + "description": "Generate context plugins that teach AI coding assistants to use your SDKs." + }, "portal": { "description": "Generate, download and serve an API Documentation portal for your APIs." }, diff --git a/src/actions/plugin/generate.ts b/src/actions/plugin/generate.ts new file mode 100644 index 00000000..7427f9b2 --- /dev/null +++ b/src/actions/plugin/generate.ts @@ -0,0 +1,68 @@ +import { withDirPath } from '../../infrastructure/tmp-extensions.js'; +import { PluginService } from '../../infrastructure/services/plugin-service.js'; +import { PluginGeneratePrompts } from '../../prompts/plugin/generate.js'; +import { BuildContext } from '../../types/build-context.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { PluginContext } from '../../types/plugin-context.js'; +import { TempContext } from '../../types/temp-context.js'; +import { ActionResult } from '../action-result.js'; + +export class PluginGenerateAction { + private readonly prompts: PluginGeneratePrompts = new PluginGeneratePrompts(); + private readonly pluginService: PluginService = new PluginService(); + private readonly configDir: DirectoryPath; + private readonly commandMetadata: CommandMetadata; + private readonly authKey: string | null; + + constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { + this.configDir = configDir; + this.commandMetadata = commandMetadata; + this.authKey = authKey; + } + + public readonly execute = async ( + buildDirectory: DirectoryPath, + pluginDirectory: DirectoryPath, + force: boolean, + zipPlugin: boolean + ): Promise => { + if (buildDirectory.isEqual(pluginDirectory)) { + this.prompts.directoryCannotBeSame(pluginDirectory); + return ActionResult.failed(); + } + + const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.validate())) { + this.prompts.srcDirectoryEmpty(buildDirectory); + return ActionResult.failed(); + } + + const pluginContext = new PluginContext(pluginDirectory); + if (!force && (await pluginContext.exists()) && !(await this.prompts.overwritePlugin(pluginDirectory))) { + this.prompts.pluginDirectoryNotEmpty(); + return ActionResult.cancelled(); + } + + return await withDirPath(async (tempDirectory) => { + const tempContext = new TempContext(tempDirectory); + const buildZipPath = await tempContext.zip(buildDirectory); + + const response = await this.prompts.generatePlugin( + this.pluginService.generatePlugin(buildZipPath, this.configDir, this.commandMetadata, this.authKey) + ); + + if (response.isErr()) { + this.prompts.pluginGenerationError(response.error.errorMessage); + return ActionResult.failed(); + } + + const tempPluginZipPath = await tempContext.save(response.value); + await pluginContext.save(tempPluginZipPath, zipPlugin); + + this.prompts.pluginGenerated(pluginDirectory); + + return ActionResult.success(); + }); + }; +} diff --git a/src/commands/plugin/generate.ts b/src/commands/plugin/generate.ts new file mode 100644 index 00000000..61a90665 --- /dev/null +++ b/src/commands/plugin/generate.ts @@ -0,0 +1,54 @@ +import { Command, Flags } from '@oclif/core'; +import { PluginGenerateAction } from '../../actions/plugin/generate.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { FlagsProvider } from '../../types/flags-provider.js'; +import { format, intro, outro } from '../../prompts/format.js'; + +export default class PluginGenerate extends Command { + static readonly summary = 'Generate a Claude Code context plugin for your published SDKs.'; + + static readonly description = + 'Generate a context plugin that teaches an AI coding assistant how to use your SDKs. Requires an input directory containing a `src` directory with a `plugin-config.json`.'; + + static readonly cmdTxt = format.cmd('apimatic', 'plugin', 'generate'); + + static readonly examples = [ + PluginGenerate.cmdTxt, + `${PluginGenerate.cmdTxt} ${format.flag('input', '"./"')} ${format.flag('destination', '"./plugin"')}` + ]; + + static readonly flags = { + zip: Flags.boolean({ + default: false, + description: 'download the generated plugin as a .zip archive' + }), + ...FlagsProvider.input, + ...FlagsProvider.destination('plugin', 'plugin'), + ...FlagsProvider.force, + ...FlagsProvider.authKey + }; + + async run(): Promise { + const { + flags: { input, destination, force, zip: zipPlugin, 'auth-key': authKey } + } = await this.parse(PluginGenerate); + + const workingDirectory = DirectoryPath.createInput(input); + const buildDirectory = workingDirectory.join('src'); + const pluginDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join('plugin'); + const commandMetadata: CommandMetadata = { + commandName: PluginGenerate.id, + shell: this.config.shell + }; + + intro('Generate Context Plugin'); + const action = new PluginGenerateAction(this.getConfigDir(), commandMetadata, authKey); + const result = await action.execute(buildDirectory, pluginDirectory, force, zipPlugin); + outro(result); + } + + private readonly getConfigDir = () => { + return new DirectoryPath(this.config.configDir); + }; +} diff --git a/src/config/axios-config.ts b/src/config/axios-config.ts index 97f258c6..9728f9ff 100644 --- a/src/config/axios-config.ts +++ b/src/config/axios-config.ts @@ -1,12 +1,19 @@ import axios from "axios"; const fiftyMBsInBytes = 50 * 1024 * 1024; -const fiveMinutesInMilliseconds = 5 * 60 * 1000; +const fourMinutesInMilliseconds = 4 * 60 * 1000; + +/** + * The single bound on any one API request, shared by the instance below and by services that + * build their own. A generation budget is only read between polls, so without this a request + * that connects and never answers would outlive it and hang the CLI. + */ +export const REQUEST_TIMEOUT_MS = fourMinutesInMilliseconds; const axiosInstance = axios.create({ maxContentLength: fiftyMBsInBytes, maxBodyLength: fiftyMBsInBytes, - timeout: fiveMinutesInMilliseconds + timeout: REQUEST_TIMEOUT_MS }); export default axiosInstance; diff --git a/src/infrastructure/generation-status-poller.ts b/src/infrastructure/generation-status-poller.ts new file mode 100644 index 00000000..5199dcb3 --- /dev/null +++ b/src/infrastructure/generation-status-poller.ts @@ -0,0 +1,92 @@ +import { Status } from '@apimatic/sdk'; +import { err, ok, Result } from 'neverthrow'; +import { ServiceError } from './service-error.js'; + +export const STATUS_POLL_INTERVAL_MS = 3000; + +/** + * Set well clear of any plausible run: it exists to end a generation that is stuck, not to + * cap a slow one. No duration data exists for real runs, so this is the number to revisit + * if a legitimate generation ever reports a timeout. + */ +export const GENERATION_TIMEOUT_MS = 30 * 60 * 1000; + +export interface GenerationStatus { + status: string; + errors?: Record; +} + +export type ValidationErrorFormatter = (errors: Record) => string; + +export interface GenerationPoll { + pollIntervalMs: number; + fetchStatus: () => Promise>; + /** + * Bounds a run whose status never reaches a terminal value. `label` opens the message. + * The budget is only read between polls, so `fetchStatus` must bound its own request — + * one that connects and never answers outlives any budget set here. + */ + timeout: { budgetMs: number; label: string }; + /** Only when the endpoint needs custom validation wording. */ + formatValidationError?: ValidationErrorFormatter; +} + +export async function pollUntilCompleted({ + pollIntervalMs, + fetchStatus, + timeout, + formatValidationError = formatValidationErrors +}: GenerationPoll): Promise> { + const deadline = Date.now() + timeout.budgetMs; + + for (;;) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + + const statusResult = await fetchStatus(); + if (statusResult.isErr()) { + return err(statusResult.error); + } + + const { status, errors } = statusResult.value; + + if (status === Status.Completed) { + return ok(statusResult.value); + } + if (status === Status.Failed) { + return err(ServiceError.ServerError); + } + if (status === Status.ValidationError) { + const validationErrors = asMessages(errors); + return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); + } + if (status === Status.SubscriptionError) { + const message = Object.values(asMessages(errors)).flat()[0]; + return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n- ' + message : ''))); + } + + // Every other status keeps the run alive rather than ending it: an endpoint reporting + // its own in-flight vocabulary is healthy, and the plugin orchestrator reports + // `Unknown` until it writes a custom status. The deadline is what stops a genuinely + // stuck run, and it is read after the status so a run that finished during the last + // wait still counts. + if (Date.now() >= deadline) { + return err(ServiceError.timeout(timedOutMessage(timeout.label, timeout.budgetMs))); + } + } +} + +export const formatValidationErrors: ValidationErrorFormatter = (errors) => { + const messages = Object.values(errors).flat(); + return 'One or more validation errors occurred.' + (messages.length ? '\n- ' + messages.join('\n- ') : ''); +}; + +const timedOutMessage = (label: string, budgetMs: number): string => { + const minutes = Math.floor(budgetMs / 60_000); + if (minutes < 1) { + return `${label} timed out.`; + } + return `${label} timed out after ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`; +}; + +const asMessages = (errors: Record | undefined): Record => + (errors ?? {}) as Record; diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index d6af9802..2ee6789a 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -10,7 +10,8 @@ export enum ServiceErrorCode { InvalidResponse = "INVALID_RESPONSE", UnAuthorized = "UNAUTHORIZED", BadRequest = "BAD_REQUEST", - Forbidden = "FORBIDDEN" + Forbidden = "FORBIDDEN", + Timeout = "TIMEOUT" } export class ServiceError { @@ -32,6 +33,9 @@ export class ServiceError { static notFound(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.NotFound, customMessage, {}); } + static timeout(customMessage: string): ServiceError { + return new ServiceError(ServiceErrorCode.Timeout, customMessage, {}); + } static unauthorizedWithHint(apiMessage: string | null): ServiceError { // Both remedies name the full `auth login` command: the key is supplied to // that command, not to whichever one hit the 401 — most of them don't accept @@ -97,6 +101,58 @@ function mapApiError(error: ApiError): ServiceError { return ServiceError.ServerError; } +interface ProblemDetailsBody { + title?: string; + detail?: string; + errors?: Record; +} + +/** + * `handleServiceError` reads a ProblemDetails body only off the SDK's typed errors, so on a raw + * axios call a 400 or 403 would collapse into a generic server error and lose its message. + * Unlike the SDK path this also falls back to `detail`, which io uses when it sends no `errors` + * map. Every other status is left to `mapTransportError`. + */ +function mapAxiosProblemDetails(error: unknown): ServiceError | undefined { + if (!axios.isAxiosError(error)) { + return undefined; + } + + const body = error.response?.data as ProblemDetailsBody | undefined; + if (typeof body !== "object" || body === null) { + return undefined; + } + + const errors = body.errors ?? {}; + const firstMessage = Object.values(errors).flat()[0] ?? body.detail; + const title = body.title ?? "Request failed."; + const message = firstMessage ? `${title}\n- ${firstMessage}` : title; + + if (error.response?.status === 400) { + return ServiceError.badRequest(message, errors); + } + if (error.response?.status === 403) { + return ServiceError.forbidden(message); + } + return undefined; +} + +/** + * `handleServiceError` maps an axios 401 onto the bare `Unauthorized access.`, which leaves the + * user with nothing to act on — the SDK path names the remedy but the axios path does not. + */ +export function mapTransportError(error: unknown): ServiceError { + if (axios.isAxiosError(error) && error.response?.status === 401) { + return ServiceError.unauthorizedWithHint(null); + } + return handleServiceError(error); +} + +/** For responses whose body is readable JSON; a streamed body can only be mapped by status. */ +export function mapRequestError(error: unknown): ServiceError { + return mapAxiosProblemDetails(error) ?? mapTransportError(error); +} + export function handleServiceError(error: unknown): ServiceError { if (error instanceof ApiError) { // A `callAsStream` error body would otherwise hang the CLI. Order against diff --git a/src/infrastructure/services/api-service.ts b/src/infrastructure/services/api-service.ts index a3301c56..c2640658 100644 --- a/src/infrastructure/services/api-service.ts +++ b/src/infrastructure/services/api-service.ts @@ -5,9 +5,6 @@ import { SubscriptionInfo } from "../../types/api/account.js"; import { envInfo } from "../env-info.js"; import { err, ok, Result } from "neverthrow"; import { handleServiceError, ServiceError } from "../service-error.js"; -import { Status } from "@apimatic/sdk"; -import { GenerationStatusEndpoint } from "../../types/api/generation-status-endpoint.js"; -import { GenerationStatusResponse } from "../../types/api/generation-status.js"; export class ApiService { private readonly apiBaseUrl = "https://api.apimatic.io" as const; @@ -35,58 +32,6 @@ export class ApiService { } } - /** - * Reads the status of one in-flight generation request. Shared by every - * async generation endpoint — see `GenerationStatusEndpoint`. - */ - public async getGenerationStatus( - endpoint: GenerationStatusEndpoint, - requestId: string, - configDir: DirectoryPath, - shell: string, - authKey: string | null - ): Promise> { - const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); - if (authInfo === null && !authKey) { - return err(ServiceError.UnAuthorized); - } - - try { - const token = authKey || authInfo?.authKey; - const response = await this.axiosInstance(shell, token).get(`${endpoint}/${requestId}/status`, { - headers: { Accept: "application/json" }, - maxRedirects: 0, - validateStatus: () => true - }); - - if (response.status === 200) { - return ok(response.data as GenerationStatusResponse); - } - - // Once generation finishes, the API redirects to the download location. - if (response.status === 302) { - return ok({ status: Status.Completed }); - } - - // `validateStatus` above stops axios throwing, so nothing reaches the - // catch block — classify the status here, or a mistyped endpoint path and - // an expired auth key both surface as a generic "unexpected error". - if (response.status === 401) { - return err(ServiceError.UnAuthorized); - } - if (response.status === 404) { - return err(ServiceError.NotFound); - } - if (response.status === 500) { - return err(ServiceError.ServerError); - } - - return err(ServiceError.InvalidResponse); - } catch (error: unknown) { - return err(handleServiceError(error)); - } - } - public async sendTelemetry(payload: string, authKey: string, shell: string): Promise> { try { const response = await this.axiosInstance(shell, authKey).post("/telemetry/track", payload, { diff --git a/src/infrastructure/services/plugin-service.ts b/src/infrastructure/services/plugin-service.ts new file mode 100644 index 00000000..d14890c2 --- /dev/null +++ b/src/infrastructure/services/plugin-service.ts @@ -0,0 +1,169 @@ +import axios from 'axios'; +import FormData from 'form-data'; +import { err, ok, Result } from 'neverthrow'; +import { AuthInfo, getAuthInfo } from '../../client-utils/auth-manager.js'; +import { REQUEST_TIMEOUT_MS } from '../../config/axios-config.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { FilePath } from '../../types/file/filePath.js'; +import { + PluginGenerationInitiatedResponse, + PluginGenerationStatus, + PluginGenerationStatusResponse +} from '../../types/plugin/generation-status.js'; +import { discardStreamBody } from '../../utils/utils.js'; +import { envInfo } from '../env-info.js'; +import { + GENERATION_TIMEOUT_MS, + pollUntilCompleted, + STATUS_POLL_INTERVAL_MS +} from '../generation-status-poller.js'; +import { FileService } from '../file-service.js'; +import { mapRequestError, mapTransportError, ServiceError } from '../service-error.js'; + +const TIMING_DEFAULTS = { + pollIntervalMs: STATUS_POLL_INTERVAL_MS, + generationTimeoutMs: GENERATION_TIMEOUT_MS, + requestTimeoutMs: REQUEST_TIMEOUT_MS +}; + +/** Overridable so tests are not paced by the production defaults; nothing else overrides them. */ +export type GenerationTimings = Partial; + +export class PluginService { + private readonly apiBaseUrl = 'https://api.apimatic.io' as const; + private readonly fileService = new FileService(); + private readonly timings: typeof TIMING_DEFAULTS; + + constructor(timings: GenerationTimings = {}) { + this.timings = { ...TIMING_DEFAULTS, ...timings }; + } + + public async generatePlugin( + buildPath: FilePath, + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + authKey: string | null + ): Promise> { + const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); + // `auth logout` blanks config.json rather than deleting it, so a logged-out user + // still has a non-null AuthInfo with an empty key — check the key, not the object. + const token = authKey || authInfo?.authKey; + if (!token) { + return err(ServiceError.unauthorizedWithHint(null)); + } + + const initiated = await this.initiateGeneration(buildPath, commandMetadata, token); + if (initiated.isErr()) { + return err(initiated.error); + } + + const generationId = initiated.value.id; + const completed = await pollUntilCompleted({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => this.getGenerationStatus(generationId, commandMetadata.shell, token), + timeout: { budgetMs: this.timings.generationTimeoutMs, label: 'Plugin generation' } + }); + if (completed.isErr()) { + return err(completed.error); + } + + return await this.downloadPlugin(generationId, commandMetadata.shell, token); + } + + private async initiateGeneration( + buildPath: FilePath, + commandMetadata: CommandMetadata, + token: string + ): Promise> { + const buildFileStream = await this.fileService.getStream(buildPath); + + try { + const formData = new FormData(); + formData.append('file', buildFileStream); + + const response = await this.axiosInstance(commandMetadata.shell, token).post('/plugin', formData, { + headers: formData.getHeaders(), + params: { origin: `APIMATIC CLI ${commandMetadata.commandName}` }, + responseType: 'json' + }); + + const id = (response.data as PluginGenerationInitiatedResponse | undefined)?.id; + return id ? ok({ id }) : err(ServiceError.InvalidResponse); + } catch (error) { + return err(mapRequestError(error)); + } finally { + buildFileStream.close(); + } + } + + private async getGenerationStatus( + generationId: string, + shell: string, + token: string + ): Promise> { + try { + const response = await this.axiosInstance(shell, token).get(`/plugin/${generationId}/status`, { + headers: { Accept: 'application/json' }, + maxRedirects: 0, + validateStatus: () => true + }); + + if (response.status === 200) { + return ok(response.data as PluginGenerationStatusResponse); + } + + // Once generation finishes, the API redirects to the download location. + if (response.status === 302) { + return ok({ status: PluginGenerationStatus.Completed }); + } + + // `validateStatus` above stops axios throwing, so nothing reaches the catch block. + if (response.status === 401) { + return err(ServiceError.unauthorizedWithHint(null)); + } + if (response.status === 404) { + return err(ServiceError.NotFound); + } + if (response.status === 500) { + return err(ServiceError.ServerError); + } + + return err(ServiceError.InvalidResponse); + } catch (error) { + return err(mapRequestError(error)); + } + } + + private async downloadPlugin( + generationId: string, + shell: string, + token: string + ): Promise> { + try { + const response = await this.axiosInstance(shell, token).get(`/plugin/${generationId}/download`, { + responseType: 'stream' + }); + return ok(response.data as NodeJS.ReadableStream); + } catch (error) { + // The body of a failed streamed response is itself a stream; leaving it open hangs the CLI. + // Discarding it also rules out reading ProblemDetails here, so only the status code maps. + if (axios.isAxiosError(error)) { + discardStreamBody(error.response?.data); + } + return err(mapTransportError(error)); + } + } + + private axiosInstance(shell: string, apiKey: string) { + return axios.create({ + baseURL: envInfo.getBaseUrl() ?? this.apiBaseUrl, + timeout: this.timings.requestTimeoutMs, + headers: { + 'User-Agent': envInfo.getUserAgent(shell), + Authorization: `X-Auth-Key ${apiKey}` + } + }); + } +} + diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index cb2ddbb7..3101fa62 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -1,4 +1,5 @@ import { ReadStream } from "fs"; +import axios from "axios"; import { ApiError, ApiResponse, @@ -28,10 +29,17 @@ import { CommandMetadata } from "../../types/common/command-metadata.js"; import { err, ok, Result } from "neverthrow"; import { Language, Stability } from "../../types/sdk/generate.js"; import { handleServiceError, ServiceError } from "../service-error.js"; -import { ApiService } from "./api-service.js"; +import { + formatValidationErrors, + GENERATION_TIMEOUT_MS, + pollUntilCompleted, + STATUS_POLL_INTERVAL_MS, + ValidationErrorFormatter +} from "../generation-status-poller.js"; +import { envInfo } from "../env-info.js"; +import { REQUEST_TIMEOUT_MS } from "../../config/axios-config.js"; import { SemVersion } from "../../types/publish/version.js"; import { TocData } from "../../types/toc/toc-components.js"; -import { GenerationStatusEndpoint } from "../../types/api/generation-status-endpoint.js"; import { GenerationStatusResponse } from "../../types/api/generation-status.js"; export interface GeneratedSdkResult { @@ -39,19 +47,23 @@ export interface GeneratedSdkResult { sdkSourceTree: NodeJS.ReadableStream; } -const STATUS_POLL_INTERVAL_MS = 3000; +const TIMING_DEFAULTS = { + pollIntervalMs: STATUS_POLL_INTERVAL_MS, + generationTimeoutMs: GENERATION_TIMEOUT_MS, + requestTimeoutMs: REQUEST_TIMEOUT_MS +}; -type FetchGenerationStatus = () => Promise>; -type ValidationErrorFormatter = (errors: Record) => string; +/** Overridable so tests are not paced by the production defaults; nothing else overrides them. */ +export type GenerationTimings = Partial; export class PortalService { private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; + private readonly apiBaseUrl = "https://api.apimatic.io" as const; private readonly fileService = new FileService(); - private readonly apiService = new ApiService(); - private readonly statusPollIntervalMs: number; + private readonly timings: typeof TIMING_DEFAULTS; - constructor(statusPollIntervalMs: number = STATUS_POLL_INTERVAL_MS) { - this.statusPollIntervalMs = statusPollIntervalMs; + constructor(timings: GenerationTimings = {}) { + this.timings = { ...TIMING_DEFAULTS, ...timings }; } // TODO: Pass stream as parameter instead of file path. @@ -83,15 +95,12 @@ export class PortalService { buildFileStream.close(); } - const statusResult = await pollUntilCompleted(this.statusPollIntervalMs, () => - this.apiService.getGenerationStatus( - GenerationStatusEndpoint.Portal, - generationId, - configDir, - commandMetadata.shell, - authKey - ) - ); + const statusResult = await pollUntilCompleted({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => + this.getPortalGenerationStatus(generationId, commandMetadata.shell, this.resolveToken(authInfo, authKey)), + timeout: { budgetMs: this.timings.generationTimeoutMs, label: "Portal generation" } + }); if (statusResult.isErr()) { return err(statusResult.error); } @@ -140,18 +149,13 @@ export class PortalService { buildFileStream.close(); } - const statusResult = await pollUntilCompleted( - this.statusPollIntervalMs, - () => - this.apiService.getGenerationStatus( - GenerationStatusEndpoint.Sdk, - generationId, - configDir, - commandMetadata.shell, - authKey - ), - formatSdkValidationError - ); + const statusResult = await pollUntilCompleted({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => + this.getSdkGenerationStatus(generationId, commandMetadata.shell, this.resolveToken(authInfo, authKey)), + timeout: { budgetMs: this.timings.generationTimeoutMs, label: "SDK generation" }, + formatValidationError: formatSdkValidationError + }); if (statusResult.isErr()) { return err(statusResult.error); } @@ -200,15 +204,12 @@ export class PortalService { buildFileStream.close(); } - const statusResult = await pollUntilCompleted(this.statusPollIntervalMs, () => - this.apiService.getGenerationStatus( - GenerationStatusEndpoint.V4Sdk, - generationId, - configDir, - commandMetadata.shell, - authKey - ) - ); + const statusResult = await pollUntilCompleted({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => + this.getV4SdkGenerationStatus(generationId, commandMetadata.shell, this.resolveToken(authInfo, authKey)), + timeout: { budgetMs: this.timings.generationTimeoutMs, label: "SDK generation" } + }); if (statusResult.isErr()) { return err(statusResult.error); } @@ -284,16 +285,157 @@ export class PortalService { } } - /** - * SDK generation reports per-language merge conflicts under a dedicated - * `sdkMergeFailed` key, which needs its own wording. Everything else falls - * back to the shared format. - */ - private createAuthorizationHeader =(authInfo: AuthInfo | null, overrideAuthKey: string | null): string => { - const key = overrideAuthKey || authInfo?.authKey; - return `X-Auth-Key ${key ?? ""}`; + private createAuthorizationHeader = (authInfo: AuthInfo | null, overrideAuthKey: string | null): string => { + return `X-Auth-Key ${this.resolveToken(authInfo, overrideAuthKey) ?? ""}`; }; + private resolveToken = (authInfo: AuthInfo | null, overrideAuthKey: string | null): string | undefined => { + return overrideAuthKey || authInfo?.authKey; + }; + + private async getPortalGenerationStatus( + requestId: string, + shell: string, + token: string | undefined + ): Promise> { + if (!token) { + return err(ServiceError.UnAuthorized); + } + + try { + const response = await this.axiosInstance(shell, token).get(`/portal/v2/${requestId}/status`, { + headers: { Accept: "application/json" }, + maxRedirects: 0, + validateStatus: () => true + }); + + if (response.status === 200) { + return ok(response.data as GenerationStatusResponse); + } + + // Once generation finishes, the API redirects to the download location. + if (response.status === 302) { + return ok({ status: Status.Completed }); + } + + // `validateStatus` above stops axios throwing, so nothing reaches the + // catch block — classify the status here, or a mistyped endpoint path and + // an expired auth key both surface as a generic "unexpected error". + if (response.status === 401) { + return err(ServiceError.UnAuthorized); + } + if (response.status === 404) { + return err(ServiceError.NotFound); + } + if (response.status === 500) { + return err(ServiceError.ServerError); + } + + return err(ServiceError.InvalidResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + + private async getSdkGenerationStatus( + requestId: string, + shell: string, + token: string | undefined + ): Promise> { + if (!token) { + return err(ServiceError.UnAuthorized); + } + + try { + const response = await this.axiosInstance(shell, token).get(`/sdk/${requestId}/status`, { + headers: { Accept: "application/json" }, + maxRedirects: 0, + validateStatus: () => true + }); + + if (response.status === 200) { + return ok(response.data as GenerationStatusResponse); + } + + // Once generation finishes, the API redirects to the download location. + if (response.status === 302) { + return ok({ status: Status.Completed }); + } + + // `validateStatus` above stops axios throwing, so nothing reaches the + // catch block — classify the status here, or a mistyped endpoint path and + // an expired auth key both surface as a generic "unexpected error". + if (response.status === 401) { + return err(ServiceError.UnAuthorized); + } + if (response.status === 404) { + return err(ServiceError.NotFound); + } + if (response.status === 500) { + return err(ServiceError.ServerError); + } + + return err(ServiceError.InvalidResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + + private async getV4SdkGenerationStatus( + requestId: string, + shell: string, + token: string | undefined + ): Promise> { + if (!token) { + return err(ServiceError.UnAuthorized); + } + + try { + const response = await this.axiosInstance(shell, token).get(`/sdk/v2/${requestId}/status`, { + headers: { Accept: "application/json" }, + maxRedirects: 0, + validateStatus: () => true + }); + + if (response.status === 200) { + return ok(response.data as GenerationStatusResponse); + } + + // Once generation finishes, the API redirects to the download location. + if (response.status === 302) { + return ok({ status: Status.Completed }); + } + + // `validateStatus` above stops axios throwing, so nothing reaches the + // catch block — classify the status here, or a mistyped endpoint path and + // an expired auth key both surface as a generic "unexpected error". + if (response.status === 401) { + return err(ServiceError.UnAuthorized); + } + if (response.status === 404) { + return err(ServiceError.NotFound); + } + if (response.status === 500) { + return err(ServiceError.ServerError); + } + + return err(ServiceError.InvalidResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + + private axiosInstance(shell: string, apiKey: string) { + return axios.create({ + baseURL: envInfo.getBaseUrl() ?? this.apiBaseUrl, + timeout: this.timings.requestTimeoutMs, + headers: { + "User-Agent": envInfo.getUserAgent(shell), + Authorization: `X-Auth-Key ${apiKey}` + } + }); + } + private createOriginQueryParameter = (commandName: string): Record => { return { origin: `APIMATIC CLI ${commandName}` @@ -316,38 +458,10 @@ export class PortalService { }; } -async function pollUntilCompleted( - pollIntervalMs: number, - fetchStatus: FetchGenerationStatus, - formatValidationError: ValidationErrorFormatter = formatValidationErrors -): Promise> { - for (;;) { - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - - const statusResult = await fetchStatus(); - if (statusResult.isErr()) { - return err(statusResult.error); - } - - const { status, errors } = statusResult.value; - - if (status === Status.Completed) { - return ok(statusResult.value); - } - if (status === Status.Failed) { - return err(ServiceError.ServerError); - } - if (status === Status.ValidationError) { - const validationErrors = asMessages(errors); - return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); - } - if (status === Status.SubscriptionError) { - const message = Object.values(asMessages(errors)).flat()[0]; - return err(ServiceError.forbidden("Access denied to resource." + (message ? "\n- " + message : ""))); - } - } -} - +/** + * SDK generation reports per-language merge conflicts under a dedicated `sdkMergeFailed` + * key, which needs its own wording. Everything else falls back to the shared format. + */ const formatSdkValidationError: ValidationErrorFormatter = (errors) => { const sdkMergeFailedLanguages = errors.sdkMergeFailed; if (sdkMergeFailedLanguages?.length) { @@ -360,11 +474,3 @@ const formatSdkValidationError: ValidationErrorFormatter = (errors) => { return formatValidationErrors(errors); }; -const formatValidationErrors: ValidationErrorFormatter = (errors) => { - const messages = Object.values(errors).flat(); - return "One or more validation errors occurred." + (messages.length ? "\n- " + messages.join("\n- ") : ""); -}; - -const asMessages = (errors: Record | undefined): Record => - (errors ?? {}) as Record; - diff --git a/src/prompts/plugin/generate.ts b/src/prompts/plugin/generate.ts new file mode 100644 index 00000000..a1572fc7 --- /dev/null +++ b/src/prompts/plugin/generate.ts @@ -0,0 +1,49 @@ +import { confirm, isCancel, log } from '@clack/prompts'; +import { Result } from 'neverthrow'; +import { ServiceError } from '../../infrastructure/service-error.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { format as f } from '../format.js'; +import { withSpinner } from '../prompt.js'; + +export class PluginGeneratePrompts { + public generatePlugin(fn: Promise>) { + return withSpinner('Generating Context Plugin', 'Plugin generated successfully.', 'Plugin Generation failed.', fn); + } + + public async overwritePlugin(directory: DirectoryPath): Promise { + const overwrite = await confirm({ + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, + initialValue: false + }); + + if (isCancel(overwrite)) { + return false; + } + + return overwrite; + } + + public directoryCannotBeSame(directory: DirectoryPath) { + const message = `The ${f.var('src')} and ${f.var('plugin')} directories must be different. Current value: ${f.path( + directory + )}`; + log.error(message); + } + + public srcDirectoryEmpty(directory: DirectoryPath) { + const message = `The ${f.var('src')} directory is either empty or invalid: ${f.path(directory)}`; + log.error(message); + } + + public pluginDirectoryNotEmpty() { + log.error('Please enter a different destination folder or remove the existing files and try again.'); + } + + public pluginGenerationError(error: string) { + log.error(error); + } + + public pluginGenerated(plugin: DirectoryPath) { + log.info(`Plugin artifacts can be found at ${f.path(plugin)}.`); + } +} diff --git a/src/types/api/generation-status-endpoint.ts b/src/types/api/generation-status-endpoint.ts deleted file mode 100644 index 2ab672ae..00000000 --- a/src/types/api/generation-status-endpoint.ts +++ /dev/null @@ -1,15 +0,0 @@ -export class GenerationStatusEndpoint { - public static readonly Portal = new GenerationStatusEndpoint("/portal/v2"); - public static readonly Sdk = new GenerationStatusEndpoint("/sdk"); - public static readonly V4Sdk = new GenerationStatusEndpoint("/sdk/v2"); - - private readonly basePath: string; - - private constructor(basePath: string) { - this.basePath = basePath; - } - - public toString(): string { - return this.basePath; - } -} diff --git a/src/types/plugin-context.ts b/src/types/plugin-context.ts new file mode 100644 index 00000000..2df756b7 --- /dev/null +++ b/src/types/plugin-context.ts @@ -0,0 +1,29 @@ +import { FileService } from '../infrastructure/file-service.js'; +import { ZipService } from '../infrastructure/zip-service.js'; +import { DirectoryPath } from './file/directoryPath.js'; +import { FileName } from './file/fileName.js'; +import { FilePath } from './file/filePath.js'; + +export class PluginContext { + private readonly fileService = new FileService(); + private readonly zipService = new ZipService(); + + constructor(private readonly pluginDirectory: DirectoryPath) {} + + private get zipPath(): FilePath { + return new FilePath(this.pluginDirectory, new FileName('plugin.zip')); + } + + public async exists(): Promise { + return !(await this.fileService.directoryEmpty(this.pluginDirectory)); + } + + public async save(tempPluginFilePath: FilePath, zipPlugin: boolean): Promise { + await this.fileService.cleanDirectory(this.pluginDirectory); + if (zipPlugin) { + await this.fileService.copy(tempPluginFilePath, this.zipPath); + } else { + await this.zipService.unArchive(tempPluginFilePath, this.pluginDirectory); + } + } +} diff --git a/src/types/plugin/generation-status.ts b/src/types/plugin/generation-status.ts new file mode 100644 index 00000000..bede1c20 --- /dev/null +++ b/src/types/plugin/generation-status.ts @@ -0,0 +1,25 @@ +/** + * Context plugin generation reports its own status vocabulary, which does not match the + * shared `Status` enum in `@apimatic/sdk`: there is no `SubscriptionError` (entitlement is + * a 403 on the generate call instead), and the in-flight values below have no equivalent. + * `Completed` is not sent on the wire — as with portal generation, a finished run is a 302 + * to the download endpoint, which `getGenerationStatus` maps onto it. + */ +export enum PluginGenerationStatus { + Queued = 'Queued', + ExecutionStarted = 'ExecutionStarted', + GeneratingArtifacts = 'GeneratingArtifacts', + Completed = 'Completed', + Failed = 'Failed', + ValidationError = 'ValidationError', + Unknown = 'Unknown' +} + +export interface PluginGenerationStatusResponse { + status: PluginGenerationStatus; + errors?: Record; +} + +export interface PluginGenerationInitiatedResponse { + id: string; +} diff --git a/test/actions/plugin/generate.test.ts b/test/actions/plugin/generate.test.ts new file mode 100644 index 00000000..592424b1 --- /dev/null +++ b/test/actions/plugin/generate.test.ts @@ -0,0 +1,139 @@ +import * as path from 'path'; +import { Readable } from 'node:stream'; +import fsExtra from 'fs-extra'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { err, ok } from 'neverthrow'; +import { dir as tmpDir, DirectoryResult } from 'tmp-promise'; +import { PluginGenerateAction } from '../../../src/actions/plugin/generate.js'; +import { PluginGeneratePrompts } from '../../../src/prompts/plugin/generate.js'; +import { PluginService } from '../../../src/infrastructure/services/plugin-service.js'; +import { ServiceError } from '../../../src/infrastructure/service-error.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { CommandMetadata } from '../../../src/types/common/command-metadata.js'; + +const COMMAND_METADATA: CommandMetadata = { commandName: 'plugin generate', shell: 'test' }; + +describe('PluginGenerateAction', () => { + let tmpDirResult: DirectoryResult; + let buildDirectory: string; + let pluginDirectory: string; + let action: PluginGenerateAction; + + const execute = (force = false, zipPlugin = true) => + action.execute(new DirectoryPath(buildDirectory), new DirectoryPath(pluginDirectory), force, zipPlugin); + + const generated = () => + sinon.stub(PluginService.prototype, 'generatePlugin').resolves(ok(Readable.from(['PK context-plugin']))); + + beforeEach(async () => { + tmpDirResult = await tmpDir({ unsafeCleanup: true }); + const workingDirectory = path.join(tmpDirResult.path, 'acme-payments'); + buildDirectory = path.join(workingDirectory, 'src'); + pluginDirectory = path.join(workingDirectory, 'plugin'); + await fsExtra.ensureDir(buildDirectory); + await fsExtra.writeJson(path.join(buildDirectory, 'APIMATIC-BUILD.json'), {}); + await fsExtra.writeJson(path.join(buildDirectory, 'plugin-config.json'), { + schemaVersion: 1, + pluginId: 'acme-payments', + pluginName: 'Acme Payments', + sdkRepos: { csharp: { sourceCode: { srcCodeUrl: 'https://github.com/acme/acme-csharp' } } } + }); + + // The spinner would render to stdout; pass the underlying promise straight through. + sinon.stub(PluginGeneratePrompts.prototype, 'generatePlugin').callsFake((fn) => fn); + + action = new PluginGenerateAction(new DirectoryPath(tmpDirResult.path), COMMAND_METADATA, 'auth-key'); + }); + + afterEach(async () => { + sinon.restore(); + await tmpDirResult.cleanup(); + }); + + describe('input guards', () => { + it('fails when the build and plugin directories are the same', async () => { + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + + const result = await action.execute( + new DirectoryPath(buildDirectory), + new DirectoryPath(buildDirectory), + false, + true + ); + + expect(result.isFailed()).to.be.true; + expect(generatePlugin.called).to.be.false; + }); + + it('fails when the build directory has no APIMATIC-BUILD.json', async () => { + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + await fsExtra.remove(path.join(buildDirectory, 'APIMATIC-BUILD.json')); + + expect((await execute()).isFailed()).to.be.true; + expect(generatePlugin.called).to.be.false; + }); + }); + + describe('overwrite guard', () => { + beforeEach(() => fsExtra.outputFile(path.join(pluginDirectory, 'stale.md'), 'from a previous run')); + + it('cancels when the destination is not empty and the user declines', async () => { + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + sinon.stub(PluginGeneratePrompts.prototype, 'overwritePlugin').resolves(false); + + expect((await execute()).isCancelled()).to.be.true; + expect(generatePlugin.called).to.be.false; + }); + + it('proceeds when the user accepts', async () => { + generated(); + sinon.stub(PluginGeneratePrompts.prototype, 'overwritePlugin').resolves(true); + + expect((await execute()).isSuccess()).to.be.true; + }); + + it('never asks when --force is set', async () => { + generated(); + const overwritePlugin = sinon.stub(PluginGeneratePrompts.prototype, 'overwritePlugin'); + + expect((await execute(true)).isSuccess()).to.be.true; + expect(overwritePlugin.called).to.be.false; + }); + }); + + describe('generation', () => { + it('saves the downloaded artifact into the plugin directory', async () => { + generated(); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(fsExtra.readFileSync(path.join(pluginDirectory, 'plugin.zip'), 'utf-8')).to.equal('PK context-plugin'); + }); + }); + + describe('generation failures', () => { + it('falls back to the plain service message for any other failure', async () => { + sinon.stub(PluginService.prototype, 'generatePlugin').resolves(err(ServiceError.ServerError)); + const pluginGenerationError = sinon.stub(PluginGeneratePrompts.prototype, 'pluginGenerationError'); + + expect((await execute()).isFailed()).to.be.true; + expect(pluginGenerationError.called).to.be.true; + }); + + it('reports every message the response carries, whatever key it arrived under', async () => { + // The service assembles one message from every key, so no key can be dropped for + // being one the CLI does not recognise. + const error = ServiceError.badRequest('One or more validation errors occurred.\n- a\n- b', { + pluginConfig: ['a'], + someKeyTheCliDoesNotKnow: ['b'] + }); + sinon.stub(PluginService.prototype, 'generatePlugin').resolves(err(error)); + const pluginGenerationError = sinon.stub(PluginGeneratePrompts.prototype, 'pluginGenerationError'); + + expect((await execute()).isFailed()).to.be.true; + expect(pluginGenerationError.firstCall.args[0]).to.equal('One or more validation errors occurred.\n- a\n- b'); + }); + }); +}); diff --git a/test/commands/examples-parse.test.ts b/test/commands/examples-parse.test.ts index 3a4bbf17..80d6299c 100644 --- a/test/commands/examples-parse.test.ts +++ b/test/commands/examples-parse.test.ts @@ -16,6 +16,7 @@ const COMMANDS: CommandMapping[] = [ { id: "auth login", fileParts: ["commands", "auth", "login.js"] }, { id: "auth logout", fileParts: ["commands", "auth", "logout.js"] }, { id: "auth status", fileParts: ["commands", "auth", "status.js"] }, + { id: "plugin generate", fileParts: ["commands", "plugin", "generate.js"] }, { id: "portal copilot", fileParts: ["commands", "portal", "copilot.js"] }, { id: "portal generate", fileParts: ["commands", "portal", "generate.js"], exportName: "PortalGenerate" }, { id: "portal recipe new", fileParts: ["commands", "portal", "recipe", "new.js"] }, diff --git a/test/infrastructure/generation-status-poller.test.ts b/test/infrastructure/generation-status-poller.test.ts new file mode 100644 index 00000000..b2e881dd --- /dev/null +++ b/test/infrastructure/generation-status-poller.test.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { ok, Result } from 'neverthrow'; +import { pollUntilCompleted } from '../../src/infrastructure/generation-status-poller'; +import { ServiceError, ServiceErrorCode } from '../../src/infrastructure/service-error'; + +// The service suites reach the timeout with millisecond budgets, which only ever renders the +// sub-minute wording. The message a user actually sees is built from a production budget, so +// the minute branches are exercised here over a fake clock rather than in real time. +describe('pollUntilCompleted', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + const neverFinishes = async (): Promise> => ok({ status: 'InProgress' }); + + const timeoutAfter = async (budgetMs: number, label: string) => { + const polling = pollUntilCompleted({ + pollIntervalMs: 1000, + fetchStatus: neverFinishes, + timeout: { budgetMs, label } + }); + await clock.tickAsync(budgetMs + 1000); + const result = await polling; + + expect(result.isErr(), 'a run that never finishes should time out').to.be.true; + const error = result._unsafeUnwrapErr(); + expect(error.code).to.equal(ServiceErrorCode.Timeout); + return error.errorMessage; + }; + + it('names the budget in minutes', async () => { + expect(await timeoutAfter(5 * 60_000, 'Plugin generation')).to.equal('Plugin generation timed out after 5 minutes.'); + }); + + it('says minute rather than minutes for a one-minute budget', async () => { + expect(await timeoutAfter(60_000, 'Portal generation')).to.equal('Portal generation timed out after 1 minute.'); + }); + + it('omits the duration when the budget is under a minute', async () => { + expect(await timeoutAfter(15, 'SDK generation')).to.equal('SDK generation timed out.'); + }); + + it('rounds down to whole minutes', async () => { + expect(await timeoutAfter(90_000, 'Portal generation')).to.equal('Portal generation timed out after 1 minute.'); + }); +}); diff --git a/test/infrastructure/services/plugin-service.test.ts b/test/infrastructure/services/plugin-service.test.ts new file mode 100644 index 00000000..281296d9 --- /dev/null +++ b/test/infrastructure/services/plugin-service.test.ts @@ -0,0 +1,367 @@ +import { expect } from 'chai'; +import http from 'node:http'; +import { AddressInfo } from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Buffer } from 'node:buffer'; +import { PluginService } from '../../../src/infrastructure/services/plugin-service'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FilePath } from '../../../src/types/file/filePath'; +import { ServiceError, ServiceErrorCode } from '../../../src/infrastructure/service-error'; +import { envInfo } from '../../../src/infrastructure/env-info'; + +describe('PluginService', () => { + const GENERATION_ID = '019fdbef-91d5-7a1f-81c2-866af5d76fe9'; + const AUTH_KEY = 'test-auth-key'; + const metadata = { commandName: 'plugin generate', shell: 'bash' }; + + let server: http.Server; + let workDir: string; + let buildPath: FilePath; + let configDir: DirectoryPath; + let service: PluginService; + + let respondToGenerate: (res: http.ServerResponse) => void; + let respondToStatus: (res: http.ServerResponse, attempt: number) => void; + let respondToDownload: (res: http.ServerResponse) => void; + + const generateRequests: { url: string; headers: http.IncomingHttpHeaders; body: string }[] = []; + const statusRequests: { url: string; headers: http.IncomingHttpHeaders }[] = []; + const downloadRequests: { url: string; headers: http.IncomingHttpHeaders }[] = []; + + const json = (res: http.ServerResponse, statusCode: number, body: unknown) => { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + const problem = (res: http.ServerResponse, statusCode: number, body: unknown) => { + res.writeHead(statusCode, { 'Content-Type': 'application/problem+json' }); + res.end(JSON.stringify(body)); + }; + + const statusBody = (body: unknown) => (res: http.ServerResponse) => json(res, 200, body); + + // A finished run is signalled by a redirect to the download endpoint, not by a status body. + const redirect = (res: http.ServerResponse) => { + res.writeHead(302, { Location: `/plugin/${GENERATION_ID}/download` }); + res.end(); + }; + + const drain = async (stream: NodeJS.ReadableStream) => { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString(); + }; + + const clearBaseUrlCache = () => { + (envInfo.constructor as unknown as { cachedBaseUrl?: string }).cachedBaseUrl = undefined; + }; + + before(async () => { + server = http.createServer((req, res) => { + const url = req.url ?? ''; + + if (req.method === 'POST') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + generateRequests.push({ url, headers: req.headers, body: Buffer.concat(chunks).toString() }); + respondToGenerate(res); + }); + return; + } + + if (url.endsWith('/status')) { + statusRequests.push({ url, headers: req.headers }); + respondToStatus(res, statusRequests.length); + return; + } + + if (url.endsWith('/download')) { + downloadRequests.push({ url, headers: req.headers }); + respondToDownload(res); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + clearBaseUrlCache(); + process.env.APIMATIC_BASE_URL = `http://127.0.0.1:${port}`; + + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-service-')); + const buildFile = path.join(workDir, 'build.zip'); + fs.writeFileSync(buildFile, Buffer.from('PK build-input')); + buildPath = FilePath.create(buildFile)!; + // No config.json here, so the explicit authKey is used. + configDir = new DirectoryPath(workDir); + // Near-zero interval and budget so the suite is not paced by the production 3s / 5min. + service = new PluginService({ pollIntervalMs: 1, generationTimeoutMs: 5000 }); + }); + + after(async () => { + delete process.env.APIMATIC_BASE_URL; + // Static cache — leaving it set would point every later suite at this closed server. + clearBaseUrlCache(); + fs.rmSync(workDir, { recursive: true, force: true }); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }); + + beforeEach(() => { + generateRequests.length = 0; + statusRequests.length = 0; + downloadRequests.length = 0; + respondToGenerate = (res) => json(res, 202, { id: GENERATION_ID }); + respondToStatus = redirect; + respondToDownload = (res) => { + res.writeHead(200, { 'Content-Type': 'application/zip' }); + res.end(Buffer.from('PK context-plugin')); + }; + }); + + const generatePlugin = (authKey: string | null = AUTH_KEY) => + service.generatePlugin(buildPath, configDir, metadata, authKey); + const errorFrom = (result: { _unsafeUnwrapErr(): unknown }) => result._unsafeUnwrapErr() as ServiceError; + + describe('generatePlugin', () => { + it('uploads the build, polls the plugin status path and downloads once complete', async () => { + const result = await generatePlugin(); + + expect(result.isOk()).to.be.true; + expect(await drain(result._unsafeUnwrap())).to.equal('PK context-plugin'); + expect(generateRequests).to.have.length(1); + expect(statusRequests[0].url).to.equal(`/plugin/${GENERATION_ID}/status`); + }); + + it('sends the build as a multipart file part, authenticated, tagged with the command origin', async () => { + await generatePlugin(); + + const request = generateRequests[0]; + expect(request.url).to.equal('/plugin?origin=APIMATIC+CLI+plugin+generate'); + expect(request.headers.authorization).to.equal(`X-Auth-Key ${AUTH_KEY}`); + expect(request.headers['content-type']).to.match(/^multipart\/form-data; boundary=/); + expect(request.body).to.include('name="file"'); + expect(request.body).to.include('PK build-input'); + }); + + it('authenticates the status and download calls too', async () => { + await generatePlugin(); + + expect(statusRequests[0].headers.authorization).to.equal(`X-Auth-Key ${AUTH_KEY}`); + expect(downloadRequests[0].headers.authorization).to.equal(`X-Auth-Key ${AUTH_KEY}`); + }); + + it('treats the redirect to the download endpoint as completion', async () => { + // The status endpoint answers a finished run with a 302, as `/portal/v2` does. Following + // it transparently would hand the zip back as a status body and poll to the timeout. + const result = await generatePlugin(); + + expect(result.isOk()).to.be.true; + expect(statusRequests).to.have.length(1); + expect(downloadRequests).to.have.length(1); + }); + + it('completes on a Completed status body too', async () => { + respondToStatus = statusBody({ status: 'Completed' }); + + expect((await generatePlugin()).isOk()).to.be.true; + }); + + it('keeps polling through every in-flight status', async () => { + const inFlight = ['Queued', 'ExecutionStarted', 'GeneratingArtifacts']; + respondToStatus = (res, attempt) => + inFlight[attempt - 1] ? json(res, 200, { status: inFlight[attempt - 1] }) : redirect(res); + + const result = await generatePlugin(); + + expect(result.isOk()).to.be.true; + expect(statusRequests).to.have.length(4); + }); + + it('refuses to call the API at all without a key', async () => { + const result = await generatePlugin(null); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.UnAuthorized); + expect(generateRequests).to.have.length(0); + }); + }); + + describe('validation failures', () => { + it('keeps the plugin-config errors addressable by key and lists every message', async () => { + respondToStatus = statusBody({ + status: 'ValidationError', + errors: { pluginConfig: ["'pluginId' must be lower-case kebab-case.", "'author.email' must be an email."] } + }); + + const error = errorFrom(await generatePlugin()); + + expect(error.code).to.equal(ServiceErrorCode.BadRequest); + expect(error.getError('pluginConfig')).to.have.length(2); + expect(error.errorMessage).to.include("'pluginId' must be lower-case kebab-case."); + expect(error.errorMessage).to.include("'author.email' must be an email."); + }); + + it('keeps the sdkRepos errors addressable by key', async () => { + respondToStatus = statusBody({ + status: 'ValidationError', + errors: { sdkRepos: ['no language in sdkRepos can be built by this generator'] } + }); + + const error = errorFrom(await generatePlugin()); + + expect(error.getError('sdkRepos')).to.deep.equal(['no language in sdkRepos can be built by this generator']); + }); + + it('terminates on a validation error that carries no messages', async () => { + respondToStatus = statusBody({ status: 'ValidationError' }); + + const error = errorFrom(await generatePlugin()); + + expect(error.errorMessage).to.equal('One or more validation errors occurred.'); + }); + + it('maps a Failed status onto a server error', async () => { + respondToStatus = statusBody({ status: 'Failed' }); + + expect(errorFrom(await generatePlugin()).code).to.equal(ServiceErrorCode.ServerError); + }); + + it('keeps polling through an Unknown status', async () => { + // The orchestrator reports Unknown until it writes its first custom status, so a run + // polled in that window is healthy rather than broken. + respondToStatus = (res, attempt) => (attempt === 1 ? json(res, 200, { status: 'Unknown' }) : redirect(res)); + + const result = await generatePlugin(); + + expect(result.isOk()).to.be.true; + expect(statusRequests).to.have.length(2); + }); + + it('gives up once the generation budget is spent', async () => { + const impatient = new PluginService({ pollIntervalMs: 1, generationTimeoutMs: 15 }); + respondToStatus = statusBody({ status: 'GeneratingArtifacts' }); + + const result = await impatient.generatePlugin(buildPath, configDir, metadata, AUTH_KEY); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.Timeout); + expect(errorFrom(result).errorMessage).to.equal('Plugin generation timed out.'); + }); + + it('gives up on a status request that never answers', async () => { + // Without a request timeout this hangs rather than fails: the generation budget is + // only read once a status call returns, and this one never does. + const impatient = new PluginService({ pollIntervalMs: 1, generationTimeoutMs: 5_000, requestTimeoutMs: 30 }); + respondToStatus = () => {}; + + const result = await impatient.generatePlugin(buildPath, configDir, metadata, AUTH_KEY); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.NetworkError); + }); + + it('reads one last status before declaring a timeout', async () => { + // The budget is spent during the wait, but the run finished in that window. + const impatient = new PluginService({ pollIntervalMs: 20, generationTimeoutMs: 10 }); + respondToStatus = redirect; + + const result = await impatient.generatePlugin(buildPath, configDir, metadata, AUTH_KEY); + + expect(result.isOk()).to.be.true; + }); + }); + + describe('request errors', () => { + it('surfaces the invalid-zip 400 that io originates', async () => { + respondToGenerate = (res) => + problem(res, 400, { + title: 'One or more validation errors occurred.', + detail: 'The provided build file is not a valid zip archive.', + errors: { file: ['build file should be in valid zip format'] } + }); + + const error = errorFrom(await generatePlugin()); + + expect(error.code).to.equal(ServiceErrorCode.BadRequest); + expect(error.errorMessage).to.equal( + 'One or more validation errors occurred.\n- build file should be in valid zip format' + ); + }); + + it('surfaces the entitlement 403 that io originates', async () => { + respondToGenerate = (res) => + problem(res, 403, { + title: 'Access denied to resource.', + errors: { '': ['Context plugin generation is not allowed on your subscription'] } + }); + + const error = errorFrom(await generatePlugin()); + + expect(error.code).to.equal(ServiceErrorCode.Forbidden); + expect(error.errorMessage).to.equal( + 'Access denied to resource.\n- Context plugin generation is not allowed on your subscription' + ); + }); + + it('falls back to detail when a problem body carries no errors map', async () => { + respondToGenerate = (res) => + problem(res, 400, { title: 'One or more validation errors occurred.', detail: "Plugin id 'x' not found" }); + + expect(errorFrom(await generatePlugin()).errorMessage).to.equal( + "One or more validation errors occurred.\n- Plugin id 'x' not found" + ); + }); + + it('points an expired key at the login command', async () => { + respondToGenerate = (res) => { + res.writeHead(401); + res.end(); + }; + + const error = errorFrom(await generatePlugin()); + + expect(error.code).to.equal(ServiceErrorCode.UnAuthorized); + // A bare 401 carries no body, so the remedy can only come from the CLI. + expect(error.errorMessage).to.equal(ServiceError.unauthorizedWithHint(null).errorMessage); + }); + + it('points an expired key at the login command when the download 401s', async () => { + respondToDownload = (res) => { + res.writeHead(401); + res.end(); + }; + + const error = errorFrom(await generatePlugin()); + + expect(error.code).to.equal(ServiceErrorCode.UnAuthorized); + expect(error.errorMessage).to.equal(ServiceError.unauthorizedWithHint(null).errorMessage); + }); + + it('reports a generate response that carries no id', async () => { + respondToGenerate = (res) => json(res, 202, {}); + + expect(errorFrom(await generatePlugin()).code).to.equal(ServiceErrorCode.InvalidResponse); + }); + + it('stops polling when the generation id is unknown', async () => { + respondToStatus = (res) => { + res.writeHead(404); + res.end(); + }; + + expect(errorFrom(await generatePlugin()).code).to.equal(ServiceErrorCode.NotFound); + expect(statusRequests).to.have.length(1); + }); + + it('maps a failed download onto a server error', async () => { + respondToDownload = (res) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ title: 'boom' })); + }; + + expect(errorFrom(await generatePlugin()).code).to.equal(ServiceErrorCode.ServerError); + }); + }); +}); diff --git a/test/infrastructure/services/portal-service.test.ts b/test/infrastructure/services/portal-service.test.ts index bf8efac1..0cb2fd3f 100644 --- a/test/infrastructure/services/portal-service.test.ts +++ b/test/infrastructure/services/portal-service.test.ts @@ -91,7 +91,7 @@ describe("PortalService generation status polling", () => { // No config.json here, so the explicit authKey is used. configDir = new DirectoryPath(workDir); // Near-zero poll interval so the suite is not paced by the 3s production default. - service = new PortalService(1); + service = new PortalService({ pollIntervalMs: 1 }); }); after(async () => { @@ -305,4 +305,54 @@ describe("PortalService generation status polling", () => { expect(statusRequests.map((request) => request.url)).to.deep.equal([`/sdk/v2/${GENERATION_ID}/status`]); }); }); + + // Each flow names itself in the timeout message, so each one's wiring is pinned separately. + // Before the shared poller these three polled a stuck generation forever. + describe("giving up on a generation that never finishes", () => { + const impatient = () => new PortalService({ pollIntervalMs: 1, generationTimeoutMs: 15 }); + + beforeEach(() => { + respondToStatus = statusBody({ status: Status.InProgress }); + }); + + it("bounds portal generation", async () => { + const result = await impatient().generatePortal(buildPath, configDir, metadata, AUTH_KEY); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.Timeout); + expect(errorFrom(result).errorMessage).to.equal("Portal generation timed out."); + }); + + it("bounds sdk generation", async () => { + const result = await impatient().generateSdk(buildPath, Language.TYPESCRIPT, configDir, metadata, AUTH_KEY); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.Timeout); + expect(errorFrom(result).errorMessage).to.equal("SDK generation timed out."); + }); + + it("bounds v4 sdk generation", async () => { + const result = await impatient().generateV4Sdk( + buildPath, + Language.CSHARP, + Stability.BETA, + configDir, + metadata, + AUTH_KEY + ); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.Timeout); + expect(errorFrom(result).errorMessage).to.equal("SDK generation timed out."); + }); + + it("gives up on a status request that never answers", async () => { + // The budget above is only read once a status call returns, so it cannot end a run + // whose poll hangs. Only the request timeout can, which is why one is set. + respondToStatus = () => {}; + + const bounded = new PortalService({ pollIntervalMs: 1, generationTimeoutMs: 5_000, requestTimeoutMs: 30 }); + + const result = await bounded.generatePortal(buildPath, configDir, metadata, AUTH_KEY); + + expect(errorFrom(result).code).to.equal(ServiceErrorCode.NetworkError); + }); + }); }); diff --git a/test/types/plugin-context.test.ts b/test/types/plugin-context.test.ts new file mode 100644 index 00000000..6874a6c6 --- /dev/null +++ b/test/types/plugin-context.test.ts @@ -0,0 +1,106 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import mockFs from 'mock-fs'; +import { expect } from 'chai'; +import { ZipService } from '../../src/infrastructure/zip-service'; +import { PluginContext } from '../../src/types/plugin-context'; +import { DirectoryPath } from '../../src/types/file/directoryPath'; +import { FileName } from '../../src/types/file/fileName'; +import { FilePath } from '../../src/types/file/filePath'; + +describe('PluginContext', () => { + const pluginDirectory = new DirectoryPath('plugin'); + const tempDirectory = new DirectoryPath('temp'); + const tempZip = new FilePath(tempDirectory, new FileName('downloaded')); + const context = new PluginContext(pluginDirectory); + + afterEach(() => mockFs.restore()); + + describe('exists', () => { + it('is false when the directory is absent', async () => { + mockFs({}); + + expect(await context.exists()).to.be.false; + }); + + it('is false when the directory holds nothing but dotfiles', async () => { + mockFs({ plugin: { '.gitkeep': '' } }); + + expect(await context.exists()).to.be.false; + }); + + it('is true when the directory holds artifacts', async () => { + mockFs({ plugin: { 'README.md': '# plugin' } }); + + expect(await context.exists()).to.be.true; + }); + }); + + describe('save', () => { + it('copies the archive as plugin.zip when asked to keep it zipped', async () => { + mockFs({ plugin: {}, temp: { downloaded: 'zip-bytes' } }); + + await context.save(tempZip, true); + + expect(fs.readFileSync(path.join(pluginDirectory.toString(), 'plugin.zip'), 'utf-8')).to.equal('zip-bytes'); + }); + + it('clears artifacts from a previous run before saving', async () => { + mockFs({ plugin: { 'stale.md': 'from a language that is gone' }, temp: { downloaded: 'zip-bytes' } }); + + await context.save(tempZip, true); + + expect(fs.existsSync(path.join(pluginDirectory.toString(), 'stale.md'))).to.be.false; + }); + + it('creates the destination directory when it does not exist yet', async () => { + mockFs({ temp: { downloaded: 'zip-bytes' } }); + + await context.save(tempZip, true); + + expect(fs.existsSync(path.join(pluginDirectory.toString(), 'plugin.zip'))).to.be.true; + }); + }); + + // The default `--zip=false` path, which every ordinary run takes. Real files rather than + // mock-fs: adm-zip reads the archive itself, so the bytes have to be a genuine zip. + describe('save, expanding the archive', () => { + let workDir: string; + let archive: FilePath; + let destination: DirectoryPath; + + beforeEach(async () => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-context-')); + const source = new DirectoryPath(path.join(workDir, 'source')); + fs.mkdirSync(path.join(source.toString(), 'skills'), { recursive: true }); + fs.writeFileSync(path.join(source.toString(), 'README.md'), '# plugin'); + fs.writeFileSync(path.join(source.toString(), 'skills', 'SKILL.md'), '# skill'); + + archive = new FilePath(new DirectoryPath(workDir), new FileName('plugin.zip')); + await new ZipService().archive(source, archive); + + destination = new DirectoryPath(path.join(workDir, 'destination')); + }); + + afterEach(() => fs.rmSync(workDir, { recursive: true, force: true })); + + it('expands the archive into the destination', async () => { + await new PluginContext(destination).save(archive, false); + + expect(fs.readFileSync(path.join(destination.toString(), 'README.md'), 'utf-8')).to.equal('# plugin'); + expect(fs.readFileSync(path.join(destination.toString(), 'skills', 'SKILL.md'), 'utf-8')).to.equal('# skill'); + expect(fs.existsSync(path.join(destination.toString(), 'plugin.zip'))).to.be.false; + }); + + it('clears a previous run before expanding', async () => { + fs.mkdirSync(destination.toString(), { recursive: true }); + fs.writeFileSync(path.join(destination.toString(), 'stale.md'), 'from a language that is gone'); + + await new PluginContext(destination).save(archive, false); + + expect(fs.existsSync(path.join(destination.toString(), 'stale.md'))).to.be.false; + expect(fs.existsSync(path.join(destination.toString(), 'README.md'))).to.be.true; + }); + }); +});