Skip to content
Open
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,14 +455,16 @@ Generate and publish an SDK to a package registry and/or source repository
```
USAGE
$ apimatic sdk publish [-p <value>] [-v <value>] [-d <value>] [-l csharp|java|php|python|ruby|typescript|go]
[-f] [-i <value>] [--publish-type package|sourcecode...] [--dry-run] [--stability stable|beta] [--git-init]
[-f] [-i <value>] [--publish-type package|sourcecode...] [--dry-run] [--stability stable|beta] [--git-init] [-k
<value>]

FLAGS
-d, --destination=<value> [default: <input>/sdk/<language>] path where the sdk will be generated.
-f, --force Never ask. Conflicts are resolved using the generator's changes, and an SDK that can't
be merged is replaced, with a warning.
-i, --input=<value> [default: ./] path to the parent directory containing the 'src' directory, which
includes API specifications and configuration files.
-k, --auth-key=<value> override current authentication state with an authentication key.
-l, --language=<option> Language of the SDK to generate and publish.
<options: csharp|java|php|python|ruby|typescript|go>
-p, --profile-id=<value> Id of the publishing profile to use.
Expand All @@ -480,8 +482,8 @@ DESCRIPTION
Generate and publish an SDK to a package registry and/or source repository

Generate and publish an SDK using a publishing profile configured in the APIMatic App. Requires an input directory
containing the API specification. Run it with no flags, or with only --git-init, for guided mode, or pass flags for
CI/CD. When you pass flags, --profile-id, --language, --version, and --publish-type are required.
containing the API specification. Run it with no flags, or with only --git-init or --auth-key, for guided mode, or
pass flags for CI/CD. When you pass flags, --profile-id, --language, --version, and --publish-type are required.
Your changes to the SDK are merged before it is published, as sdk generate does with git 2.38 or later: with flags,
always; in guided mode, add --git-init. Nothing is published while conflicts are left to resolve.

Expand Down
14 changes: 10 additions & 4 deletions src/actions/sdk/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export class SdkPublishAction {
private readonly launcherService: LauncherService = new LauncherService();
private readonly gitService: GitService = new GitService();

public constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {}
public constructor(
private readonly configDir: DirectoryPath,
private readonly commandMetadata: CommandMetadata,
private readonly authKey: string | null = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the raw key now travels through three action constructors and three service methods next to configDir, and each service decides flag-or-login on its own. Resolving it once in the command and passing the result down would remove the repeated lookups.

) {}

public readonly execute = async (
project: ProjectContext,
Expand Down Expand Up @@ -54,7 +58,7 @@ export class SdkPublishAction {
await packageSettingsContext.writeConfiguration(packageConfigurationData, language);
}

const sdkGenerateAction = new GenerateAction(this.configDir, this.commandMetadata);
const sdkGenerateAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey);
const sdkGenerationResult = await sdkGenerateAction.execute(
project,
sdkDirectory,
Expand Down Expand Up @@ -95,7 +99,8 @@ export class SdkPublishAction {
semVersion,
publishType,
this.configDir,
this.commandMetadata.shell
this.commandMetadata.shell,
this.authKey
)
);

Expand Down Expand Up @@ -125,7 +130,8 @@ export class SdkPublishAction {
this.publishingApiService.getSdkPublishingLog(
publishingInfo.publishLogId,
this.configDir,
this.commandMetadata.shell
this.commandMetadata.shell,
this.authKey
)
);

Expand Down
10 changes: 7 additions & 3 deletions src/actions/sdk/publish/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export class SdkPublishInteractiveAction {
private readonly prompts: SdkPublishInteractivePrompts = new SdkPublishInteractivePrompts();
private readonly publishingApiService: PublishingApiService = new PublishingApiService();

public constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {}
public constructor(
private readonly configDir: DirectoryPath,
private readonly commandMetadata: CommandMetadata,
private readonly authKey: string | null = null
) {}

public readonly execute = async (
defaultProjectDirectory: DirectoryPath,
Expand All @@ -35,7 +39,7 @@ export class SdkPublishInteractiveAction {
const project = ProjectContext.in(workingDirectory);

const publishingProfilesResponse = await this.prompts.getPublishingProfiles(
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell)
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell, this.authKey)
);
if (publishingProfilesResponse.isErr()) {
this.prompts.getPublishingProfilesServiceError(publishingProfilesResponse.error);
Expand Down Expand Up @@ -126,7 +130,7 @@ export class SdkPublishInteractiveAction {
}

const publishingProfileId = ProfileId.createFromPublishingProfileItem(publishingProfileItem);
const publishResult = await new SdkPublishAction(this.configDir, this.commandMetadata).execute(
const publishResult = await new SdkPublishAction(this.configDir, this.commandMetadata, this.authKey).execute(
project,
sdkDirectory,
language,
Expand Down
10 changes: 7 additions & 3 deletions src/actions/sdk/publish/non-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export class SdkPublishNonInteractiveAction {
private readonly prompts: SdkPublishNonInteractivePrompts = new SdkPublishNonInteractivePrompts();
private readonly publishingApiService: PublishingApiService = new PublishingApiService();

public constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {}
public constructor(
private readonly configDir: DirectoryPath,
private readonly commandMetadata: CommandMetadata,
private readonly authKey: string | null = null
) {}

public readonly execute = async (
project: ProjectContext,
Expand Down Expand Up @@ -63,7 +67,7 @@ export class SdkPublishNonInteractiveAction {
}

const publishingProfilesResponse = await this.prompts.getPublishingProfiles(
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell)
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell, this.authKey)
);
if (publishingProfilesResponse.isErr()) {
this.prompts.getPublishingProfilesServiceError(publishingProfilesResponse.error);
Expand Down Expand Up @@ -108,7 +112,7 @@ export class SdkPublishNonInteractiveAction {
stability: stability.chosenLevel()
});
// A run with flags is the CI flow: it never waits on a question, whether a terminal is attached or not, and always keeps the SDK's edits.
const publishResult = await new SdkPublishAction(this.configDir, this.commandMetadata).execute(
const publishResult = await new SdkPublishAction(this.configDir, this.commandMetadata, this.authKey).execute(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing tests that run() passes authKey to either action, or that this action and SdkPublishInteractiveAction forward this.authKey to getPublishingProfiles (line 70 here, interactive.ts:42) and to SdkPublishAction (here, interactive.ts:133). Dropping any of them would still pass the suite while breaking -k for generation and upload. A test per action asserting the key reaches the service stub would cover it.

project,
sdkDirectory,
language,
Expand Down
25 changes: 14 additions & 11 deletions src/commands/sdk/publish.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command, Flags } from '@oclif/core';
import { Command, Flags, Interfaces } from '@oclif/core';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FlagsProvider } from '../../types/flags-provider.js';
import { Language, Stability } from '../../types/sdk/generate.js';
Expand All @@ -15,7 +15,7 @@ import { SdkPublishNonInteractiveAction } from '../../actions/sdk/publish/non-in
export default class SdkPublish extends Command {
static readonly summary = 'Generate and publish an SDK to a package registry and/or source repository';

static readonly description = `Generate and publish an SDK using a publishing profile configured in the APIMatic App. Requires an input directory containing the API specification. Run it with no flags, or with only --git-init, for guided mode, or pass flags for CI/CD. When you pass flags, --profile-id, --language, --version, and --publish-type are required.
static readonly description = `Generate and publish an SDK using a publishing profile configured in the APIMatic App. Requires an input directory containing the API specification. Run it with no flags, or with only --git-init or --auth-key, for guided mode, or pass flags for CI/CD. When you pass flags, --profile-id, --language, --version, and --publish-type are required.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"with only --git-init or --auth-key" reads as one or the other, but both together are guided too (the PR body says "and/or"). Maybe "with only --git-init and/or --auth-key", here and in apimatic-docs#884.

Your changes to the SDK are merged before it is published, as sdk generate does with git 2.38 or later: with flags, always; in guided mode, add --git-init. Nothing is published while conflicts are left to resolve.`;

static readonly cmdTxt = format.cmd('apimatic', 'sdk', 'publish');
Expand Down Expand Up @@ -56,7 +56,8 @@ Your changes to the SDK are merged before it is published, as sdk generate does
default: false,
description:
"In guided mode, make the SDK directory a git repository when it isn't one of its own, so your changes to the SDK are kept. With flags, sdk publish always does this."
})
}),
...FlagsProvider.authKey
};

static examples = [
Expand All @@ -80,9 +81,9 @@ Your changes to the SDK are merged before it is published, as sdk generate does
)}`
];

/** Guided mode is asked for by no flags, or `--git-init` alone. */
static isGuided(argv: readonly string[]): boolean {
return argv.every((arg) => arg === '--git-init');
/** Guided mode is asked for by no flags, or only `--git-init` and `--auth-key`. */
static isGuided(raw: Interfaces.ParserOutput['raw']): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list of flags that don't count against guided mode will grow with every cross-cutting flag. It already leaves out -i, even though guided mode reads input (DirectoryPath.createInput(input) can never get a value), so apimatic sdk publish -i ./proj -k KEY fails as a flag run. Defining guided as "none of the flags that describe a publish were passed" (profile-id, language, version, publish-type, destination, force, dry-run, stability) might age better.

return raw.every((token) => token.type === 'flag' && (token.flag === 'git-init' || token.flag === 'auth-key'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-k on its own now selects guided mode, but nothing checks whether a terminal is attached (sdk generate gates this on terminal.isInteractive()). In CI, apimatic sdk publish -k $APIMATIC_KEY used to fail straight away as an unknown flag; now it starts the guided prompts and waits or gets cancelled at the working-directory question, instead of failing with "Missing required flag(s)". Could guided mode require terminal.isInteractive() too?

}

async run() {
Expand All @@ -97,12 +98,14 @@ Your changes to the SDK are merged before it is published, as sdk generate does
'publish-type': publishType,
'dry-run': dryRun,
stability,
'git-init': gitInit
}
'git-init': gitInit,
'auth-key': authKey
},
raw
} = await this.parse(SdkPublish);

const publishTypes = [...new Set(publishType)] as PublishType[];
const interactive = SdkPublish.isGuided(this.argv);
const interactive = SdkPublish.isGuided(raw);
const commandMetadata: CommandMetadata = {
commandName: SdkPublish.id,
shell: this.config.shell
Expand Down Expand Up @@ -134,12 +137,12 @@ Your changes to the SDK are merged before it is published, as sdk generate does
const sdkDirectory = destination === undefined ? undefined : DirectoryPath.fromUserInput(destination);
intro('Publish SDK');
const result = interactive
? await new SdkPublishInteractiveAction(configDir, commandMetadata).execute(
? await new SdkPublishInteractiveAction(configDir, commandMetadata, authKey).execute(
DirectoryPath.createInput(input),
gitInit,
onPublishSdkError
)
: await new SdkPublishNonInteractiveAction(configDir, commandMetadata).execute(
: await new SdkPublishNonInteractiveAction(configDir, commandMetadata, authKey).execute(
project,
sdkDirectory,
language as Language,
Expand Down
27 changes: 13 additions & 14 deletions src/infrastructure/services/publishing-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ export class PublishingApiService {
authKey: string | null = null
): Promise<Result<PublishingProfileItem[], ServiceError>> {
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.
// `auth logout` blanks config.json rather than deleting it, so check the key, not the AuthInfo.
const token = authKey || authInfo?.authKey;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolve-and-check block now appears in all three methods, and SdkGenerationService.resolveToken plus five other services do the same thing. A shared helper in client-utils/auth-manager.ts, e.g. resolveAuthKey(configDir, authKey), would keep rules like rejecting an empty key in one place.

if (!token) {
return err(ServiceError.unauthorizedWithHint(null));
Expand All @@ -51,25 +50,25 @@ export class PublishingApiService {
packageVersion: SemVersion,
publishType: PublishType[],
configDir: DirectoryPath,
shell: string
shell: string,
authKey: string | null = null
): Promise<Result<PublishingInfo, ServiceError>> {
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.
if (!authInfo?.authKey) {
// `auth logout` blanks config.json rather than deleting it, so check the key, not the AuthInfo.
const token = authKey || authInfo?.authKey;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty key (-k "" or --auth-key=, e.g. from an unset CI secret) is accepted, and authKey || authInfo?.authKey then quietly falls back to the stored login. On a machine that's logged in, the SDK gets published under that account; otherwise the user is told to run auth login, with no hint that the flag was empty. auth login rejects an empty key, and FlagsProvider.nonEmpty could do the same for the shared flag.

if (!token) {
return err(ServiceError.unauthorizedWithHint(null));
}
const sdkFileStream = await this.fileService.getStream(sdkFilePath);

try {
const token = authInfo.authKey;
const formData = new FormData();
formData.append('file', sdkFileStream);
formData.append('packageVersion', packageVersion.toString());

// If both package and source code publishing are not selected, publishType array will contain only one of them, so we need to specify which one to the API.
// If both package and source code publishing are not selected, publishType array will contain only one of them, so we need to specify which one to the API.
// If both are selected, we will omit this field as API defaults to both types being selected.
if (publishType.length !== 2) {
if (publishType.length !== 2) {
formData.append('publishType', publishType[0]);
}

Expand Down Expand Up @@ -109,17 +108,17 @@ export class PublishingApiService {
public async getSdkPublishingLog(
publishingLogId: string,
configDir: DirectoryPath,
shell: string
shell: string,
authKey: string | null = null
): Promise<Result<PublishLogItem, ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: getAuthInfo reads config.json on every call even when a flag key was given, including every 10-second poll of the publish log. const token = authKey || (await getAuthInfo(configDir.toString()))?.authKey skips the read.

// `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.
if (!authInfo?.authKey) {
// `auth logout` blanks config.json rather than deleting it, so check the key, not the AuthInfo.
const token = authKey || authInfo?.authKey;
if (!token) {
return err(ServiceError.unauthorizedWithHint(null));
}

try {
const token = authInfo.authKey;
const response = await this.axiosInstance(shell, token).get(`/publish-logs/${publishingLogId}`);

if (response.status === 200) {
Expand Down
41 changes: 40 additions & 1 deletion test/actions/sdk/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { FileProblem } from '../../../src/types/file/file-problem.js';
import { GitContext } from '../../../src/types/git-context.js';
import { ProjectContext } from '../../../src/types/project-context.js';
import { PublishingProfileItem, PublishType } from '../../../src/types/publish-api/publishing-profile-item.js';
import { PublishLogItem } from '../../../src/types/publish-api/publish-log.js';
import { ProfileId } from '../../../src/types/publish/profile-id.js';
import { PublishingProfile } from '../../../src/types/publish/publishing-profile.js';
import { SemVersion } from '../../../src/types/publish/version.js';
Expand All @@ -34,6 +35,7 @@ const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk publish', shell: '
describe('SdkPublishAction', () => {
let root: string;
let generates: (files: Files) => Promise<void>;
let generateSdk: sinon.SinonStub;
let onPublishSdkError: sinon.SinonStub;
let restoreConfig: () => void;

Expand Down Expand Up @@ -63,7 +65,7 @@ describe('SdkPublishAction', () => {

beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-publish-'));
({ generates } = stubSdkGeneration(root));
({ generates, generateSdk } = stubSdkGeneration(root));
await generates({ 'README.md': '# sdk' });
onPublishSdkError = sinon.stub();
restoreConfig = emptyGlobalGitConfig(root);
Expand Down Expand Up @@ -115,6 +117,43 @@ describe('SdkPublishAction', () => {
expect(fs.existsSync(path.join(sdk().toString(), 'node_modules', 'dep.js'))).to.be.true;
});

it('uses the auth key it was given to generate, upload and follow the publish', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this repeats three of stubPublishing()'s stubs and the 14-argument call from the execute helper. An authKey option on execute and a poll callback on stubPublishing would shrink this to a few lines.

quietGeneration();
sinon.stub(SdkPublishPrompts.prototype, 'publishSdk').callsFake((fn) => fn);
sinon.stub(SdkPublishPrompts.prototype, 'publishingRunningNotice');
sinon.stub(SdkPublishPrompts.prototype, 'publishingLogsMessage');
sinon.stub(SdkPublishPrompts.prototype, 'pollPublishingStatus').callsFake(async (poll) => {
await poll();
return 'succeeded';
});
const publishSdkPackage = uploads([]);
const getSdkPublishingLog = sinon
.stub(PublishingApiService.prototype, 'getSdkPublishingLog')
.resolves(ok({} as PublishLogItem));

const result = await new SdkPublishAction(new DirectoryPath(root), COMMAND_METADATA, 'flag-key').execute(
ProjectContext.in(new DirectoryPath(root)),
sdk(),
Language.PYTHON,
Stability.STABLE,
[PublishType.PackagePublishing],
true,
false,
true,
ProfileId.tryCreate('profile')._unsafeUnwrap(),
SemVersion.tryCreate('1.0.0')._unsafeUnwrap(),
withoutPackageSettings,
false,
'summary',
onPublishSdkError
);

expect(result.isSuccess()).to.be.true;
expect(generateSdk.firstCall.args[5]).to.equal('flag-key');
expect(publishSdkPackage.firstCall.args[7]).to.equal('flag-key');
expect(getSdkPublishingLog.firstCall.args[3]).to.equal('flag-key');
});

it('generates into the SDK directory on a dry run and opens it, uploading nothing', async () => {
quietGeneration();
const dryRunNotice = sinon.stub(SdkPublishPrompts.prototype, 'dryRunNotice');
Expand Down
24 changes: 19 additions & 5 deletions test/commands/sdk/publish-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,25 @@ describe('sdk publish flags', () => {
expect(omitted.flags['git-init']).to.be.false;
});

it('runs guided with no flags or --git-init alone, and with flags otherwise', () => {
expect(SdkPublish.isGuided([])).to.be.true;
expect(SdkPublish.isGuided(['--git-init'])).to.be.true;
expect(SdkPublish.isGuided(['--git-init', '--dry-run'])).to.be.false;
expect(SdkPublish.isGuided(['--language', 'python'])).to.be.false;
it('runs guided with no flags or only --git-init and --auth-key, and with flags otherwise', async () => {
const guided = async (argv: string[]) => SdkPublish.isGuided((await parse(argv)).raw);

expect(await guided([])).to.be.true;
expect(await guided(['--git-init'])).to.be.true;
expect(await guided(['--auth-key', 'key'])).to.be.true;
expect(await guided(['--auth-key=key'])).to.be.true;
expect(await guided(['-k', 'key'])).to.be.true;
expect(await guided(['--git-init', '-k', 'key'])).to.be.true;
expect(await guided(['--git-init', '--dry-run'])).to.be.false;
expect(await guided(['-k', 'key', '--language', 'python'])).to.be.false;
});

it('accepts an auth key, long or short', async () => {
const long = (await parse(['--auth-key', 'key'])) as never as { flags: Record<string, unknown> };
const short = (await parse(['-k', 'key'])) as never as { flags: Record<string, unknown> };

expect(long.flags['auth-key']).to.equal('key');
expect(short.flags['auth-key']).to.equal('key');
});

it('still parses the flags a publish is actually made of', async () => {
Expand Down
Loading
Loading