Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/actions/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const DEFAULT_COPILOT_WELCOME_MESSAGE =
"Ask me anything about this API or try one of these example prompts:\n" +
"\n" +
"- `What authentication methods does this API support?`\n" +
"- `What endpoints are available in this API?`\n" ;
"- `What is this API about?`\n" ;

export class CopilotAction {
private readonly apiService = new ApiService();
Expand Down
68 changes: 52 additions & 16 deletions src/actions/portal/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { FeaturesToRemove, ValidationService } from '../../infrastructure/servic
import { FileName } from '../../types/file/fileName.js';
import { ApiService } from '../../infrastructure/services/api-service.js';
import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js';
import { mapLanguages } from '../../types/sdk/generate.js';

const defaultPort: number = 23513 as const;
const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`);
Expand Down Expand Up @@ -56,6 +57,22 @@ export class PortalQuickstartAction {
}

return await withDirPath<ActionResult>(async (tempDirectory: DirectoryPath): Promise<ActionResult> => {
// Fetch account info before anything else so the plan is known up front: it
// gates the on-prem generation exit below, feeds the language step the allowed
// SDK languages, and resolves the API Copilot key later. A lookup failure is fatal.
const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null);
if (accountInfo.isErr()) {
this.prompts.accountInfoFetchFailed(accountInfo.error);
return ActionResult.failed();
}
// Quickstart generates the portal locally (on-prem); a plan that doesn't allow
// on-prem generation can't run it, so stop before importing or pruning a spec.
if (!accountInfo.value.isOnPremGenerationAllowed) {
this.prompts.onPremGenerationNotAllowedOnPlan();
return ActionResult.cancelled();
}
const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages);

// Step 1/4
this.prompts.importSpecStep();

Expand Down Expand Up @@ -131,10 +148,19 @@ export class PortalQuickstartAction {

// Step 3/4
this.prompts.selectLanguagesStep();
const languages = await this.prompts.selectLanguagesPrompt();
if (!languages) {
this.prompts.noLanguagesSelected();
return ActionResult.cancelled();
let languages: string[];
if (allowedLanguages.length === 0) {
// With no SDK languages on the plan there's nothing to select, so skip the
// menu and build the portal with HTTP documentation only.
this.prompts.httpOnlyPortalOnPlan();
languages = ['http'];
} else {
const selectedLanguages = await this.prompts.selectLanguagesPrompt(allowedLanguages);
if (!selectedLanguages) {
this.prompts.noLanguagesSelected();
return ActionResult.cancelled();
}
languages = selectedLanguages;
}

// Step 4/4
Expand All @@ -161,15 +187,10 @@ export class PortalQuickstartAction {
}

// Resolve the API Copilot key to enable, if any, before setting up the source
// directory so the user decides on Copilot up front. The lookup failing is fatal;
// an account with no key continues silently (no Copilot); cancelling the multi-key
// selection aborts quickstart.
const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null);
if (accountInfo.isErr()) {
this.prompts.accountInfoFetchFailed(accountInfo.error);
return ActionResult.failed();
}

// directory. An account with no key continues silently (no Copilot); cancelling
// the multi-key selection aborts quickstart. (Account info was already fetched
// above for the language step.) Whether Copilot is actually on the plan is only
// known after the prune below, so the "enabled" caution is deferred until then.
let copilotKey: string | undefined;
const copilotKeys = accountInfo.value.ApiCopilotKeys ?? [];
if (copilotKeys.length === 1) {
Expand All @@ -181,9 +202,6 @@ export class PortalQuickstartAction {
return ActionResult.cancelled();
}
}
if (copilotKey) {
this.prompts.copilotEnabled(copilotKey);
}

const masterBuildFile = await this.prompts.downloadBuildDirectory(
this.fileDownloadService.downloadFile(this.buildFileUrl)
Expand Down Expand Up @@ -214,6 +232,24 @@ export class PortalQuickstartAction {
: baseConfig;
await buildContext.updateBuildFileContents(buildConfig);

// Prune the build file to what the plan allows (SDK languages + AI features)
// before serving. Fail closed: a prune failure aborts rather than serving a
// build the plan can't generate.
const pruneResult = await this.validationService.pruneBuildFile(buildContext.buildConfigFilePath());
if (pruneResult.isErr()) {
this.prompts.serviceError(pruneResult.error);
return ActionResult.failed();
}
const { buildFile: prunedConfig, report } = pruneResult.value;
await buildContext.updateBuildFileContents(prunedConfig);
this.prompts.buildFilePruned(report);

// Only surface the Copilot caution if Copilot survived the prune — i.e. it's
// actually on the plan. If it was stripped, buildFilePruned already reported it.
if (prunedConfig.hasApiCopilot() && copilotKey) {
this.prompts.copilotEnabled(copilotKey);
}

const specDirectory = sourceDirectory.join('spec');
const specContext = new SpecContext(specDirectory);
await specContext.replaceDefaultSpec(specPath);
Expand Down
21 changes: 18 additions & 3 deletions src/actions/sdk/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ 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 { CodeGenerationVersion, Language, Stability } from '../../types/sdk/generate.js';
import { CodeGenerationVersion, Language, mapLanguages, Stability } 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 { ApiService } from '../../infrastructure/services/api-service.js';

export class SdkQuickstartAction {
private readonly prompts = new SdkQuickstartPrompts();
private readonly fileDownloadService = new FileDownloadService();
private readonly fileService = new FileService();
private readonly launcherService = new LauncherService();
private readonly zipService = new ZipService();
private readonly apiService = new ApiService();
private readonly validationService = new ValidationService(this.configDir);
private readonly metadataFileUrl = new UrlPath(
`https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/APIMATIC-META.json`
Expand All @@ -44,6 +46,20 @@ export class SdkQuickstartAction {
}

return await withDirPath<ActionResult>(async (tempDirectory: DirectoryPath): Promise<ActionResult> => {
// Fetch account info before anything else so the plan is known up front: it
// gates the free-plan exit below and feeds the language step the allowed SDK
// languages. A lookup failure is fatal.
const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null);
if (accountInfo.isErr()) {
this.prompts.accountInfoFetchFailed(accountInfo.error);
return ActionResult.failed();
}
// An SDK needs a language; with none on the plan (e.g. the free plan) there's
// nothing to generate, so stop before importing or pruning a spec.
if (mapLanguages(accountInfo.value.allowedLanguages).length === 0) {
this.prompts.noLanguagesAvailableOnPlan();
return ActionResult.cancelled();
}
// Step 1/4
this.prompts.importSpecStep();

Expand Down Expand Up @@ -120,8 +136,7 @@ export class SdkQuickstartAction {

// Step 3/4
this.prompts.selectLanguageStep();

const language = await this.prompts.selectLanguagePrompt();
const language = await this.prompts.selectLanguagePrompt(mapLanguages(accountInfo.value.allowedLanguages));
if (!language) {
this.prompts.noLanguageSelected();
return ActionResult.cancelled();
Expand Down
108 changes: 106 additions & 2 deletions src/infrastructure/services/validation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js";
import { apiClientFactory } from "./api-client-factory.js";
import { err, ok, Result } from "neverthrow";
import { FilePath } from "../../types/file/filePath.js";
import { FileName } from "../../types/file/fileName.js";
import { CommandMetadata } from "../../types/common/command-metadata.js";
import FormData from "form-data";
import { ZipService } from "../zip-service.js";
import { FileService } from "../file-service.js";
import { withDirPath } from "../tmp-extensions.js";
import { handleServiceError, ServiceError } from "../service-error.js";
import axios, { AxiosResponse } from "axios";
import axios from "axios";
import { envInfo } from "../env-info.js";
import { Buffer } from "node:buffer";
import { BuildConfig } from "../../types/build/build.js";

export enum RemovableFeature {
Merging = 'Merging',
Expand Down Expand Up @@ -53,8 +58,21 @@ export interface ValidateApiResponse {
unallowedFeatures: UnallowedFeaturesResponse | null;
}

export interface BuildFilePruneReport {
removedLanguages: string[];
removedApiCopilot: boolean;
removedAiIntegration: boolean;
}

export interface PruneBuildFileResponse {
buildFile: BuildConfig;
report: BuildFilePruneReport;
}

export class ValidationService {
private readonly apiBaseUrl = "https://api.apimatic.io" as const;
private readonly zipService = new ZipService();
private readonly fileService = new FileService();

constructor(private readonly configDir: DirectoryPath) {}

Expand Down Expand Up @@ -132,6 +150,90 @@ export class ValidationService {
}
}

/**
* Prunes a build file down to what the user's subscription allows (SDK languages +
* AI features) via the platform, returning the pruned build file and a report of
* what was removed. The platform is the entitlement authority, so the build we
* submit for generation is never rejected for a build-file feature the plan lacks.
*/
public async pruneBuildFile(
buildConfigFilePath: FilePath,
authKey?: string | null
): Promise<Result<PruneBuildFileResponse, ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString());
const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null);

const formData = new FormData();
formData.append("file", createReadStream(buildConfigFilePath.toString()), {
filename: "APIMATIC-BUILD.json",
contentType: "application/json"
});

const baseURL = envInfo.getBaseUrl() ?? this.apiBaseUrl;

try {
const response = await axios({
method: "POST",
url: `${baseURL}/build-features/prune`,
data: formData,
headers: {
...formData.getHeaders(),
Authorization: authorizationHeader
},
// The endpoint returns a zip (pruned APIMATIC-BUILD.json + report.json),
// streamed so it can be written straight to disk and unarchived.
responseType: "stream",
validateStatus: () => true
});

if (response.status >= 400) {
return err(await this.parsePruneErrorResponse(response));
}

// Persist the zip to a temp dir and extract it via ZipService, then read the
// two entries back off disk — no in-memory zip handling.
return await withDirPath<Result<PruneBuildFileResponse, ServiceError>>(async (tempDir) => {
const zipPath = new FilePath(tempDir, new FileName("prune-response.zip"));
await this.fileService.writeFile(zipPath, response.data);

const extractDir = tempDir.join("prune");
await this.zipService.unArchive(zipPath, extractDir);

const prunedBuildConfigFile = new FilePath(extractDir, new FileName("APIMATIC-BUILD.json"));
const reportFile = new FilePath(extractDir, new FileName("report.json"));
if (!(await this.fileService.fileExists(prunedBuildConfigFile)) || !(await this.fileService.fileExists(reportFile))) {
return err(ServiceError.ServerError);
}

const buildConfigFile = BuildConfig.parse(await this.fileService.getContents(prunedBuildConfigFile));
const report = JSON.parse(await this.fileService.getContents(reportFile)) as BuildFilePruneReport;
return ok({ buildFile: buildConfigFile, report });
});
} catch (error: unknown) {
return err(handleServiceError(error));
}
}

/** Decodes a streamed error body into a ServiceError with a prune-specific fallback message. */
private async parsePruneErrorResponse(
response: { status: number; data: AsyncIterable<Buffer> }
): Promise<ServiceError> {
const chunks: Buffer[] = [];
for await (const chunk of response.data) {
chunks.push(Buffer.from(chunk));
}
const errorBody = Buffer.concat(chunks).toString("utf-8");

let message = `Error ${response.status}: Failed to prune the build file for your subscription.`;
try {
const body = JSON.parse(errorBody) as { errors?: { summary?: string[] }; message?: string; title?: string };
message = body?.errors?.summary?.[0] ?? body?.message ?? body?.title ?? message;
} catch {
// Non-JSON / undecodable body — keep the default message.
}
return ServiceError.badRequest(message, {});
}

private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string {
const key = overrideAuthKey || authInfo?.authKey;
return `X-Auth-Key ${key ?? ""}`;
Expand All @@ -158,7 +260,9 @@ export class ValidationService {
return "Unexpected error occurred while validating API specification.";
}

private async parseErrorResponse(response: AxiosResponse): Promise<ServiceError> {
private async parseErrorResponse(
response: { status: number; data: AsyncIterable<Buffer> }
): Promise<ServiceError> {
const chunks: Buffer[] = [];
for await (const chunk of response.data) {
chunks.push(Buffer.from(chunk));
Expand Down
Loading