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
27 changes: 12 additions & 15 deletions packages/api/src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Command, Option } from 'commander';
import figures from 'figures';
import Oas from 'oas';
import ora from 'ora';
import validateNPMPackageName from 'validate-npm-package-name';

import Fetcher from '../../fetcher';
import codegen from '../codegen';
Expand All @@ -18,6 +17,7 @@ cmd
.name('install')
.description('install an API SDK into your codebase')
.argument('<uri>', 'an API to install')
.option('-i, --identifier <identifier>', 'API identifier (eg. `@api/petstore`)')
.addOption(
new Option('-l, --lang <language>', 'SDK language').choices([
'js', // User generally wants JS, we'll prompt if they want CJS or ESM files.
Expand All @@ -27,7 +27,7 @@ cmd
])
)
.addOption(new Option('-y, --yes', 'Automatically answer "yes" to any prompts printed'))
.action(async (uri: string, options: { lang: string; yes?: boolean }) => {
.action(async (uri: string, options: { identifier?: string; lang: string; yes?: boolean }) => {
let language: SupportedLanguages;
if (options.lang) {
language = options.lang as SupportedLanguages;
Expand Down Expand Up @@ -69,7 +69,12 @@ cmd
}

let identifier;
if (Fetcher.isAPIRegistryUUID(uri)) {
if (options.identifier) {
// `Storage.isIdentifierValid` will throw an exception if an identifier is invalid.
if (Storage.isIdentifierValid(options.identifier)) {
identifier = options.identifier;
}
} else if (Fetcher.isAPIRegistryUUID(uri)) {
identifier = Fetcher.getProjectPrefixFromRegistryUUID(uri);
} else {
({ value: identifier } = await promptTerminal({
Expand All @@ -82,19 +87,11 @@ cmd
return false;
}

// Is this identifier already in storage?
if (Storage.isInLockFile({ identifier: value })) {
return `"${value}" is already taken in your \`.api/\` directory. Please enter another identifier.`;
}

const isValidForNPM = validateNPMPackageName(`@api/${value}`);
if (!isValidForNPM.validForNewPackages) {
// `prompts` doesn't support surfacing multiple errors in a `validate` call so we can
// only surface the first to the user.
return isValidForNPM.errors[0];
try {
return Storage.isIdentifierValid(value, true);
} catch (err) {
return err.message;
}

return true;
},
}));
}
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/cli/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'path';

import makeDir from 'make-dir';
import ssri from 'ssri';
import validateNPMPackageName from 'validate-npm-package-name';

import Fetcher from '../fetcher';
import { PACKAGE_VERSION } from '../packageInfo';
Expand Down Expand Up @@ -105,6 +106,22 @@ export default class Storage {
return Storage.lockfile;
}

static isIdentifierValid(identifier: string, prefixWithAPINamespace?: boolean) {
// Is this identifier already in storage?
if (Storage.isInLockFile({ identifier })) {
throw new Error(`"${identifier}" is already taken in your \`.api/\` directory. Please try another identifier.`);
}

const isValidForNPM = validateNPMPackageName(prefixWithAPINamespace ? `@api/${identifier}` : identifier);
if (!isValidForNPM.validForNewPackages) {
// `prompts` doesn't support surfacing multiple errors in a `validate` call so we can only
// surface the first to the user.
throw new Error(`Identifier cannot be used for an NPM package: ${isValidForNPM.errors[0]}`);
}

return true;
}

static isInLockFile(search: { identifier?: string; source?: string }) {
// Because this method may run before we initialize a new storage object we should make sure
// that we have a storage directory present.
Expand Down
33 changes: 33 additions & 0 deletions packages/api/test/cli/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ describe('storage', () => {
});
});

describe('#isIdentifierValid', () => {
it('should allow an identifier that is valid', () => {
expect(Storage.isIdentifierValid('buster')).toBe(true);
expect(Storage.isIdentifierValid('buster', true)).toBe(true);
});

it('should throw an error for when an identifier is already being used', async () => {
fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple);

const source = '@petstore/v1.0#n6kvf10vakpemvplx';
const storage = new Storage(source, 'petstore');

expect(Storage.isInLockFile({ source })).toBe(false);

await storage.load();

expect(() => {
Storage.isIdentifierValid('petstore');
}).toThrow('"petstore" is already taken in your `.api/` directory. Please try another identifier.');
});

it("should throw an error when an identifier can't be used on npm", () => {
expect(() => {
Storage.isIdentifierValid('.buster');
}).toThrow('Identifier cannot be used for an NPM package: name cannot start with a period');

expect(() => {
// `true` here will try to check it as `@api/ buster`, `@api/.buster` is valid apparently!
Storage.isIdentifierValid(' buster', true);
}).toThrow('Identifier cannot be used for an NPM package: name can only contain URL-friendly characters');
});
});

describe('#isInLockFile', () => {
it('should be able to look up in the lockfile by a given source', async () => {
fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple);
Expand Down