diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3453b26b..57117b7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,6 +122,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Wait for GH assets to be downloadable + - name: Wait for assets (10s) + run: sleep 10s + shell: bash + # Update Homebrew formula to the new version # TODO: !!! Run only on stable releases - name: Update Homebrew Formula diff --git a/.gitignore b/.gitignore index 36717acd..07dd04a4 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ yalc.lock testground /test/ + +# Superface Comlink artifacts +superface/ \ No newline at end of file diff --git a/package.json b/package.json index 41bbf31f..1fa527fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@superfaceai/cli", - "version": "4.0.0-beta.8", + "version": "4.0.0-beta.14", "description": "Superface CLI utility", "main": "dist/index.js", "repository": "https://github.com/superfaceai/cli.git", @@ -15,6 +15,7 @@ "dist/" ], "scripts": { + "prebuild": "rimraf dist", "build": "tsc -p tsconfig.release.json --outDir dist", "test": "yarn test:fast && yarn test:integration", "test:clean": "jest --clear-cache && jest", @@ -74,9 +75,6 @@ "oclif": { "commands": "dist/commands", "bin": "superface", - "hooks": { - "init": "dist/hooks/init" - }, "plugins": [ "@oclif/plugin-warn-if-update-available" ], diff --git a/src/commands/execute.ts b/src/commands/execute.ts index 0c4f9f17..3dba81cf 100644 --- a/src/commands/execute.ts +++ b/src/commands/execute.ts @@ -37,14 +37,12 @@ export default class Execute extends Command { required: true, }, { - // TODO: add language support name: 'language', description: 'Language which will use generated code. Default is `js`.', - // TODO: this will be required when we support more languages required: false, default: 'js', - options: Object.keys(SupportedLanguages), - // Hidden because we support only js for now + options: Object.values(SupportedLanguages), + // Hidden until we figure better language select DX hidden: true, }, ]; diff --git a/src/commands/map.test.ts b/src/commands/map.test.ts index 59943f27..f0bc7c0d 100644 --- a/src/commands/map.test.ts +++ b/src/commands/map.test.ts @@ -2,13 +2,16 @@ import { parseProfile, Source } from '@superfaceai/parser'; import { MockLogger } from '../common'; import { createUserError } from '../common/error'; +import { fetchSDKToken } from '../common/http'; import { exists, readFile } from '../common/io'; import { OutputStream } from '../common/output-stream'; import { UX } from '../common/ux'; +import type { NewDotenv } from '../logic'; import { + createNewDotenv, SupportedLanguages, writeApplicationCode, -} from '../logic/application-code/application-code'; +} from '../logic/application-code'; import { mapProviderToProfile } from '../logic/map'; import { prepareProject } from '../logic/project'; import { mockProviderJson } from '../test/provider-json'; @@ -17,8 +20,10 @@ import Map from './map'; jest.mock('../common/io'); jest.mock('../common/output-stream'); +jest.mock('../common/http'); jest.mock('../logic/map'); jest.mock('../logic/application-code/application-code'); +jest.mock('../logic/application-code/dotenv'); jest.mock('../logic/project'); describe('MapCLI command', () => { @@ -44,6 +49,11 @@ describe('MapCLI command', () => { requiredParameters: ['TEST_PARAMETER'], requiredSecurity: ['TEST_SECURITY'], }; + const mockDotenv: NewDotenv = { + content: 'TEST_PARAMETER=\nTEST_SECURITY=', + newEmptyEnvVariables: ['TEST_PARAMETER', 'TEST_SECURITY'], + }; + const mockToken = { token: 'sfs_b31314b7fc8...8ec1930e' }; const providerJson = mockProviderJson({ name: providerName }); const userError = createUserError(false); const ux = UX.create(); @@ -237,7 +247,8 @@ describe('MapCLI command', () => { jest.mocked(prepareProject).mockResolvedValueOnce({ saved: true, - installationGuide: 'test', + dependencyInstallCommand: 'make install', + languageDependency: 'TestLang > 18', path: 'test', }); @@ -255,6 +266,9 @@ describe('MapCLI command', () => { .mocked(writeApplicationCode) .mockResolvedValueOnce(mockApplicationCode); + jest.mocked(fetchSDKToken).mockResolvedValueOnce(mockToken); + jest.mocked(createNewDotenv).mockReturnValueOnce(mockDotenv); + jest.mocked(mapProviderToProfile).mockResolvedValueOnce(mapSource); await instance.execute({ @@ -292,7 +306,8 @@ describe('MapCLI command', () => { jest.mocked(prepareProject).mockResolvedValueOnce({ saved: true, - installationGuide: 'test', + dependencyInstallCommand: 'make install', + languageDependency: 'TestLang > 18', path: 'test', }); @@ -310,6 +325,9 @@ describe('MapCLI command', () => { .mocked(writeApplicationCode) .mockResolvedValueOnce(mockApplicationCode); + jest.mocked(fetchSDKToken).mockResolvedValueOnce(mockToken); + jest.mocked(createNewDotenv).mockReturnValueOnce(mockDotenv); + jest.mocked(mapProviderToProfile).mockResolvedValueOnce(mapSource); await instance.execute({ @@ -367,7 +385,8 @@ describe('MapCLI command', () => { jest.mocked(prepareProject).mockResolvedValueOnce({ saved: true, - installationGuide: 'test', + dependencyInstallCommand: 'make install', + languageDependency: 'TestLang > 18', path: 'test', }); @@ -387,6 +406,9 @@ describe('MapCLI command', () => { .mocked(writeApplicationCode) .mockResolvedValueOnce(mockApplicationCode); + jest.mocked(fetchSDKToken).mockResolvedValueOnce(mockToken); + jest.mocked(createNewDotenv).mockReturnValueOnce(mockDotenv); + await instance.execute({ logger, userError, @@ -440,7 +462,8 @@ describe('MapCLI command', () => { jest.mocked(prepareProject).mockResolvedValueOnce({ saved: true, - installationGuide: 'test', + dependencyInstallCommand: 'make install', + languageDependency: 'TestLang > 18', path: 'test', }); @@ -460,6 +483,9 @@ describe('MapCLI command', () => { .mocked(writeApplicationCode) .mockResolvedValueOnce(mockApplicationCode); + jest.mocked(fetchSDKToken).mockResolvedValueOnce(mockToken); + jest.mocked(createNewDotenv).mockReturnValueOnce(mockDotenv); + await instance.execute({ logger, userError, diff --git a/src/commands/map.ts b/src/commands/map.ts index 94c3c4ee..4b39b43f 100644 --- a/src/commands/map.ts +++ b/src/commands/map.ts @@ -8,23 +8,38 @@ import { stringifyError } from '../common/error'; import { buildMapPath, buildProfilePath, + buildProjectDotenvFilePath, buildRunFilePath, } from '../common/file-structure'; -import { SuperfaceClient } from '../common/http'; -import { exists, readFile } from '../common/io'; +import { formatPath } from '../common/format'; +import { fetchSDKToken, SuperfaceClient } from '../common/http'; +import { exists, readFile, readFileQuiet } from '../common/io'; import type { ILogger } from '../common/log'; import { OutputStream } from '../common/output-stream'; import { ProfileId } from '../common/profile'; import { resolveProviderJson } from '../common/provider'; import { UX } from '../common/ux'; +import { createNewDotenv } from '../logic'; import { - getLanguageName, SupportedLanguages, writeApplicationCode, } from '../logic/application-code/application-code'; import { mapProviderToProfile } from '../logic/map'; import { prepareProject } from '../logic/project'; +type Status = { + filesCreated: string[]; + dotenv?: { + path: string; + newVars: string[]; + }; + execution?: { + languageDependency: string; + dependencyInstallCommand: string; + executeCommand: string; + }; +}; + export default class Map extends Command { // TODO: add description public static description = @@ -48,9 +63,8 @@ export default class Map extends Command { name: 'language', description: 'Language which will use generated code. Default is `js`.', required: false, - default: 'js', options: Object.values(SupportedLanguages), - // Hidden because we support only js for now + // Hidden until we figure better language select DX hidden: true, }, ]; @@ -85,6 +99,7 @@ export default class Map extends Command { const { providerName, profileId, language } = args; const resolvedLanguage = resolveLanguage(language, { userError }); + const hasExplicitLanguageSelect = language !== undefined; ux.start('Loading profile'); const profile = await resolveProfileSource(profileId, { userError }); @@ -95,6 +110,10 @@ export default class Map extends Command { client: SuperfaceClient.getClient(), }); + const status: Status = { + filesCreated: [], + }; + ux.start('Preparing integration code for your use case'); // TODO: load old map? const map = await mapProviderToProfile( @@ -111,7 +130,8 @@ export default class Map extends Command { providerName: resolvedProviderJson.providerJson.name, profileScope: profile.ast.header.scope, }); - ux.succeed(`Integration code saved to ${mapPath}`); + + status.filesCreated.push(mapPath); ux.start(`Preparing boilerplate code for ${resolvedLanguage}`); @@ -124,18 +144,17 @@ export default class Map extends Command { userError, } ); - ux.succeed( - boilerplate.saved - ? `Boilerplate code prepared for ${resolvedLanguage} at ${boilerplate.path}` - : `Boilerplate for ${getLanguageName( - resolvedLanguage - )} already exists at ${boilerplate.path}.` - ); + if (boilerplate.saved) { + status.filesCreated.push(boilerplate.path); + } - if (boilerplate.envVariables !== undefined) { - ux.warn( - `Please set the following environment variables before running the integration:\n${boilerplate.envVariables}` - ); + const dotenv = await saveDotenv(resolvedProviderJson.providerJson); + + if (dotenv.newEnvVariables.length > 0) { + status.dotenv = { + path: dotenv.dotenvPath, + newVars: dotenv.newEnvVariables, + }; } ux.start(`Setting up local project in ${resolvedLanguage}`); @@ -143,21 +162,21 @@ export default class Map extends Command { // TODO: install dependencies const project = await prepareProject(resolvedLanguage); - if (project.saved) { - ux.succeed( - `Dependency definition prepared for ${getLanguageName( - resolvedLanguage - )} at ${project.path}.` - ); - } + const executeCommand = makeExecuteCommand({ + providerName: resolvedProviderJson.providerJson.name, + profileScope: profile.scope, + profileName: profile.name, + resolvedLanguage, + hasExplicitLanguageSelect, + }); - ux.warn(project.installationGuide); + status.execution = { + languageDependency: project.languageDependency, + dependencyInstallCommand: project.dependencyInstallCommand, + executeCommand: executeCommand, + }; - ux.succeed( - `Local project set up. You can now install defined dependencies and run \`superface execute ${resolvedProviderJson.providerJson.name - } ${ProfileId.fromScopeName(profile.scope, profile.name).id - }\` to execute your integration.` - ); + ux.succeed(makeMessage(status)); } } @@ -188,7 +207,7 @@ async function saveBoilerplateCode( profileAst: ProfileDocumentNode, language: SupportedLanguages, { userError, logger }: { userError: UserError; logger: ILogger } -): Promise<{ saved: boolean; path: string; envVariables: string | undefined }> { +): Promise<{ saved: boolean; path: string }> { const path = buildRunFilePath({ profileName: profileAst.header.name, providerName: providerJson.name, @@ -200,7 +219,6 @@ async function saveBoilerplateCode( return { saved: false, path, - envVariables: undefined, }; } @@ -216,22 +234,37 @@ async function saveBoilerplateCode( } ); - let envVariables: string | undefined; - if (code.requiredParameters.length > 0 || code.requiredSecurity.length > 0) { - envVariables = code.requiredParameters - .map(p => `Integration parameter ${p}`) - .join('\n'); - envVariables += - '\n' + - code.requiredSecurity.map(s => `Security variable ${s}`).join('\n'); - } - await OutputStream.writeOnce(path, code.code); return { saved: true, path, - envVariables, + }; +} + +async function saveDotenv( + providerJson: ProviderJson +): Promise<{ dotenvPath: string; newEnvVariables: string[] }> { + const dotenvPath = buildProjectDotenvFilePath(); + + const { token } = await fetchSDKToken(); + const existingDotenv = await readFileQuiet(dotenvPath); + + const newDotenv = createNewDotenv({ + previousDotenv: existingDotenv, + providerName: providerJson.name, + parameters: providerJson.parameters, + security: providerJson.securitySchemes, + token, + }); + + if (newDotenv.content) { + await OutputStream.writeOnce(dotenvPath, newDotenv.content); + } + + return { + dotenvPath, + newEnvVariables: newDotenv.newEmptyEnvVariables, }; } @@ -321,3 +354,53 @@ async function saveMap({ return mapPath; } + +function makeExecuteCommand({ + providerName, + profileScope, + profileName, + resolvedLanguage, + hasExplicitLanguageSelect, +}: { + providerName: string; + profileScope: string | undefined; + profileName: string; + resolvedLanguage: SupportedLanguages; + hasExplicitLanguageSelect: boolean; +}): string { + const sfExecute = `superface execute ${providerName} ${ + ProfileId.fromScopeName(profileScope, profileName).id + }`; + + return hasExplicitLanguageSelect + ? `${sfExecute} ${resolvedLanguage}` + : sfExecute; +} + +function makeMessage(status: Status): string { + let message = `📡 Comlink established!`; + + if (status.filesCreated.length > 0) { + message += ` + +Files created: +${status.filesCreated.map(file => `- ${formatPath(file)}`).join('\n')}`; + } + + if (status.dotenv) { + message += ` + +Set the following environment variables in '${formatPath(status.dotenv.path)}': +${status.dotenv.newVars.map(env => `- $${env}`).join('\n')}`; + } + + if (status.execution) { + message += ` + +Run (${status.execution.languageDependency}): +cd superface && ${status.execution.dependencyInstallCommand} +${status.execution.executeCommand}`; + } + + return message; +} diff --git a/src/commands/new.ts b/src/commands/new.ts index 9dc8e025..0e047a0f 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -4,6 +4,7 @@ import type { Flags } from '../common/command.abstract'; import { Command } from '../common/command.abstract'; import type { UserError } from '../common/error'; import { buildProfilePath } from '../common/file-structure'; +import { formatPath } from '../common/format'; import { SuperfaceClient } from '../common/http'; import { exists } from '../common/io'; import { OutputStream } from '../common/output-stream'; @@ -88,9 +89,12 @@ export default class New extends Command { const profilePath = await saveProfile(profile, { userError }); ux.succeed( - `Profile saved to ${profilePath}. You can use it to generate integration code for your use case by running 'superface map ${ - resolvedProviderJson.providerJson.name - } ${ProfileId.fromScopeName(profile.scope, profile.name).id}'` + `New Comlink profile saved to '${formatPath(profilePath)}'. + +Create your use case code by running: +superface map ${resolvedProviderJson.providerJson.name} ${ + ProfileId.fromScopeName(profile.scope, profile.name).id + }` ); } } diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index c80ec87b..49bc713c 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -9,6 +9,7 @@ import { buildProviderPath, buildSuperfaceDirPath, } from '../common/file-structure'; +import { formatPath } from '../common/format'; import { exists, mkdir, readFile } from '../common/io'; import type { ILogger } from '../common/log'; import { OutputStream } from '../common/output-stream'; @@ -129,13 +130,18 @@ This command prepares a Provider JSON metadata definition that can be used to ge (providerJson.services.length === 1 && providerJson.services[0].baseUrl.includes('TODO')) ) { - // TODO: provide more info - url to REAL docs - ux.warn( - `[ACTION REQUIRED]: Provider definition is incomplete. Please fill in the details at ${providerJsonPath}. Documentation Guide:\nhttps://sfc.is/editing-providers\nYou can then create a new profile using 'superface new ${providerJson.name} ""'.` - ); + ux.warn(`[ACTION REQUIRED]: Provider definition requires attention. Please check and edit '${formatPath( + providerJsonPath + )}' (reference: https://sfc.is/editing-providers). + +Create a new Comlink profile using: +superface new ${providerJson.name} "use case description"`); } else { ux.succeed( - `Provider definition saved to ${providerJsonPath}.\nYou can now create a new profile using 'superface new ${providerJson.name} ""'.` + `Provider definition saved to '${formatPath(providerJsonPath)}'. + +Create a new Comlink profile using: +superface new ${providerJson.name} "use case description"` ); } } diff --git a/src/common/error.ts b/src/common/error.ts index f4ba67a2..5f30749d 100644 --- a/src/common/error.ts +++ b/src/common/error.ts @@ -12,16 +12,16 @@ import { UX } from './ux'; */ export const createUserError = (emoji: boolean) => - (message: string, code: number): CLIError => { - // Make sure that UX is stoped before throwing an error. - UX.clear(); + (message: string, code: number): CLIError => { + // Make sure that UX is stoped before throwing an error. + UX.clear(); - if (code <= 0) { - throw developerError('expected positive error code', 1); - } + if (code <= 0) { + throw developerError('expected positive error code', 1); + } - return new CLIError(emoji ? '❌ ' + message : message, { exit: code }); - }; + return new CLIError(emoji ? '❌ ' + message : message, { exit: code }); + }; export type UserError = ReturnType; export type DeveloperError = typeof developerError; diff --git a/src/common/file-structure.test.ts b/src/common/file-structure.test.ts index 61bcd303..2c042a59 100644 --- a/src/common/file-structure.test.ts +++ b/src/common/file-structure.test.ts @@ -3,6 +3,7 @@ import { buildMapPath, buildProfilePath, buildProjectDefinitionFilePath, + buildProjectDotenvFilePath, buildProviderPath, buildRunFilePath, buildSuperfaceDirPath, @@ -122,4 +123,12 @@ describe('fileStructure', () => { ); }); }); + + describe('buildProjectDotenvFilePath', () => { + it('builds project .env file path', () => { + expect(buildProjectDotenvFilePath()).toEqual( + expect.stringContaining(`/superface/.env`) + ); + }); + }); }); diff --git a/src/common/file-structure.ts b/src/common/file-structure.ts index f867fad9..36ec53c4 100644 --- a/src/common/file-structure.ts +++ b/src/common/file-structure.ts @@ -89,3 +89,7 @@ export function buildProjectDefinitionFilePath( return join(buildSuperfaceDirPath(), FILENAME_MAP[language]); } + +export function buildProjectDotenvFilePath(): string { + return join(buildSuperfaceDirPath(), '.env'); +} diff --git a/src/common/format.test.ts b/src/common/format.test.ts index 9c807d3b..9cc0fdb3 100644 --- a/src/common/format.test.ts +++ b/src/common/format.test.ts @@ -1,4 +1,6 @@ -import { capitalize, startCase } from './format'; +import { join } from 'path'; + +import { capitalize, formatPath, startCase } from './format'; describe('Format utils', () => { describe('when calling startCase', () => { @@ -20,4 +22,33 @@ describe('Format utils', () => { expect(capitalize('getUser')).toEqual('GetUser'); }); }); + + describe('formatPath', () => { + const HOME_PATH = '/Users/admin'; + const APP_PATH = join(HOME_PATH, 'Documents/my-app'); + const SUPERFACE_DIR_PATH = join(APP_PATH, 'superface'); + const PROFILE_PATH = join(SUPERFACE_DIR_PATH, 'scope.name.profile'); + + it('formats path to Profile from ~/', () => { + expect(formatPath(PROFILE_PATH, HOME_PATH)).toEqual( + 'Documents/my-app/superface/scope.name.profile' + ); + }); + + it('formats path to Profile from app root', () => { + expect(formatPath(PROFILE_PATH, APP_PATH)).toEqual( + 'superface/scope.name.profile' + ); + }); + + it('formats path to Profile from Superface directory', () => { + expect(formatPath(PROFILE_PATH, SUPERFACE_DIR_PATH)).toEqual( + 'scope.name.profile' + ); + }); + + it('formats path to app root from Superface directory', () => { + expect(formatPath(APP_PATH, SUPERFACE_DIR_PATH)).toEqual('..'); + }); + }); }); diff --git a/src/common/format.ts b/src/common/format.ts index d332da6e..32d2f18c 100644 --- a/src/common/format.ts +++ b/src/common/format.ts @@ -1,3 +1,5 @@ +import { relative } from 'path'; + export function formatWordPlurality(num: number, word: string): string { if (num === 1) { return `${num} ${word}`; @@ -22,3 +24,10 @@ export function startCase(input: string, delimiter = ' '): string { '' ); } + +export function formatPath( + absolutePath: string, + relativeTo = process.cwd() +): string { + return relative(relativeTo, absolutePath); +} diff --git a/src/common/http.test.ts b/src/common/http.test.ts index c3228620..baef48fb 100644 --- a/src/common/http.test.ts +++ b/src/common/http.test.ts @@ -1,6 +1,7 @@ import type { AstMetadata, ProviderJson } from '@superfaceai/ast'; import { ApiKeyPlacement, HttpScheme, SecurityType } from '@superfaceai/ast'; import { ServiceApiError, ServiceClient } from '@superfaceai/service-client'; +import { UserAccountType } from '@superfaceai/service-client/dist/interfaces/identity_api_response'; import type { SuperfaceClient } from '../common/http'; import { @@ -11,6 +12,7 @@ import { fetchProfileInfo, fetchProviderInfo, fetchProviders, + fetchSDKToken, getServicesUrl, } from '../common/http'; import { mockResponse } from '../test/utils'; @@ -802,4 +804,119 @@ describe('HTTP functions', () => { ); }, 10000); }); + + describe('when fetching default OneSDK token', () => { + const ACCNT_HANDLE = 'testuser'; + const DEFAULT_PROJECT = 'default-project'; + const TOKEN = 'sfs_b31314b7fc8...8ec1930e'; + + const mockUserResponse = { + name: 'test.user', + email: 'test.user@superface.test', + accounts: [ + { + handle: ACCNT_HANDLE, + type: UserAccountType.PERSONAL, + }, + ], + }; + + const mockGetProjectResponse = { + url: `https://superface.test/projects/${ACCNT_HANDLE}/${DEFAULT_PROJECT}`, + name: DEFAULT_PROJECT, + sdk_auth_tokens: [ + { + token: TOKEN, + created_at: '2023-07-03T13:58:08.235Z', + }, + ], + settings: { + email_notifications: true, + }, + created_at: '2023-07-03T13:58:08.231Z', + }; + + const mockUserError = new ServiceApiError({ + status: 401, + title: 'Unauthorized', + detail: '', + instance: '/id/user', + }); + + const mockGetProjectError = new ServiceApiError({ + status: 404, + instance: `/projects/${ACCNT_HANDLE}/default-projec`, + title: 'Project not found', + detail: `Project ${ACCNT_HANDLE}/default-projec doesn't exist`, + }); + + let getUserInfoSpy: jest.SpyInstance; + let getProjectSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.resetAllMocks(); + + getUserInfoSpy = jest + .spyOn(ServiceClient.prototype, 'getUserInfo') + .mockResolvedValue(mockUserResponse); + + getProjectSpy = jest + .spyOn(ServiceClient.prototype, 'getProject') + .mockResolvedValue(mockGetProjectResponse); + }); + + it('returns token (obtains account handle, then fetches project by handle and name)', async () => { + const result = await fetchSDKToken(); + + expect(getUserInfoSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledWith(ACCNT_HANDLE, DEFAULT_PROJECT); + + expect(result).toStrictEqual({ token: TOKEN }); + }); + + it('returns null as a token when obtaining account handle fails', async () => { + getUserInfoSpy = jest + .spyOn(ServiceClient.prototype, 'getUserInfo') + .mockRejectedValue(mockUserError); + + const result = await fetchSDKToken(); + + expect(getUserInfoSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledTimes(0); + + expect(result).toStrictEqual({ token: null }); + }); + + it('returns null as a token when fetching the project fails', async () => { + getProjectSpy = jest + .spyOn(ServiceClient.prototype, 'getProject') + .mockRejectedValue(mockGetProjectError); + + const result = await fetchSDKToken(); + + expect(getUserInfoSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledWith(ACCNT_HANDLE, DEFAULT_PROJECT); + + expect(result).toStrictEqual({ token: null }); + }); + + it('returns null as a token when the project fetched lacks tokens', async () => { + getProjectSpy = jest + .spyOn(ServiceClient.prototype, 'getProject') + .mockResolvedValue({ + ...mockGetProjectResponse, + sdk_auth_tokens: [], + }); + + const result = await fetchSDKToken(); + + expect(getUserInfoSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledTimes(1); + expect(getProjectSpy).toHaveBeenCalledWith(ACCNT_HANDLE, DEFAULT_PROJECT); + + expect(result).toStrictEqual({ token: null }); + }); + }); }); diff --git a/src/common/http.ts b/src/common/http.ts index ef65be83..0527dcde 100644 --- a/src/common/http.ts +++ b/src/common/http.ts @@ -165,3 +165,22 @@ export async function fetchMapAST(id: { return assertMapDocumentNode(JSON.parse(response)); } + +export async function fetchSDKToken( + defaultProjectName = 'default-project' +): Promise<{ token: string | null }> { + const client = SuperfaceClient.getClient(); + + try { + const userInfo = await client.getUserInfo(); + const accountHandle = userInfo.accounts[0].handle; + + const project = await client.getProject(accountHandle, defaultProjectName); + + const token = project.sdk_auth_tokens?.[0].token ?? null; + + return { token }; + } catch (_) { + return { token: null }; + } +} diff --git a/src/common/polling.ts b/src/common/polling.ts index fdc13f9d..05cf0090 100644 --- a/src/common/polling.ts +++ b/src/common/polling.ts @@ -19,24 +19,24 @@ enum PollResultType { } type PollResponse = | { - result_url: string; - status: PollStatus.Success; - result_type: PollResultType; - } + result_url: string; + status: PollStatus.Success; + result_type: PollResultType; + } | { - status: PollStatus.Pending; - events: { - occuredAt: Date; - type: string; - description: string; - }[]; - result_type: PollResultType; - } + status: PollStatus.Pending; + events: { + occuredAt: Date; + type: string; + description: string; + }[]; + result_type: PollResultType; + } | { - status: PollStatus.Failed; - failure_reason: string; - result_type: PollResultType; - } + status: PollStatus.Failed; + failure_reason: string; + result_type: PollResultType; + } | { status: PollStatus.Cancelled; result_type: PollResultType }; function isPollResponse(input: unknown): input is PollResponse { @@ -119,10 +119,12 @@ export async function pollUrl( if (result.status === PollStatus.Success) { ux.succeed(`Successfully finished operation`); - return result.result_url; + +return result.result_url; } else if (result.status === PollStatus.Failed) { throw userError( - `Failed to ${getJobDescription(result.result_type)}: ${result.failure_reason + `Failed to ${getJobDescription(result.result_type)}: ${ + result.failure_reason }`, 1 ); diff --git a/src/common/ux.ts b/src/common/ux.ts index 00eb23c5..017e033d 100644 --- a/src/common/ux.ts +++ b/src/common/ux.ts @@ -29,7 +29,6 @@ export class UX { public info(text: string): void { if (text.trim() !== this.lastText.trim()) { - this.spinner.clear(); this.spinner.update({ text }); } diff --git a/src/hooks/init.ts b/src/hooks/init.ts deleted file mode 100644 index c5722226..00000000 --- a/src/hooks/init.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Hook } from '@oclif/config'; -import { VERSION as SDK_VERSION } from '@superfaceai/one-sdk'; -import { VERSION as PARSER_VERSION } from '@superfaceai/parser'; - -export const hook: Hook<'init'> = async function (_options) { - this.config.userAgent += ` (with @superfaceai/one-sdk@${SDK_VERSION}, @superfaceai/parser@${PARSER_VERSION})`; -}; diff --git a/src/logic/application-code/application-code.ts b/src/logic/application-code/application-code.ts index e9a27ae0..cf4e1aea 100644 --- a/src/logic/application-code/application-code.ts +++ b/src/logic/application-code/application-code.ts @@ -98,7 +98,7 @@ export async function writeApplicationCode( // TODO: this should be language independent and also take use case name as input let inputExample: string; try { - inputExample = prepareUseCaseInput(profileAst); + inputExample = prepareUseCaseInput(profileAst, language); } catch (error) { // TODO: fallback to empty object? throw userError( diff --git a/src/logic/application-code/dotenv/dotenv.test.ts b/src/logic/application-code/dotenv/dotenv.test.ts new file mode 100644 index 00000000..7e4bad21 --- /dev/null +++ b/src/logic/application-code/dotenv/dotenv.test.ts @@ -0,0 +1,178 @@ +import type { IntegrationParameter, SecurityScheme } from '@superfaceai/ast'; +import { HttpScheme, SecurityType } from '@superfaceai/ast'; + +import { createNewDotenv } from './dotenv'; + +const PROVIDER_NAME = 'my-provider'; +const PARAMETER: IntegrationParameter = { + name: 'param-one', + description: 'Parameter description', +}; +const PARAMETER_WITH_DEFAULT: IntegrationParameter = { + name: 'param-two', + default: 'us-west-1', + description: 'Deployment zone\nfor AWS', +}; +const BASIC_AUTH: SecurityScheme = { + id: 'basic_auth', + type: SecurityType.HTTP, + scheme: HttpScheme.BASIC, +}; +const BEARER_AUTH: SecurityScheme = { + id: 'bearer_auth', + type: SecurityType.HTTP, + scheme: HttpScheme.BEARER, +}; +const TOKEN = 'sfs_b31314b7fc8...8ec1930e'; + +const EXISTING_DOTENV = `SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e +# Deployment zone +# for AWS +MY_PROVIDER_PARAM_TWO=us-west-1 +MY_PROVIDER_TOKEN= +`; + +describe('createNewDotenv', () => { + describe('when there is no previous .env', () => { + it('creates valid .env when no token, no parameters or security schemes are given', () => { + const result = createNewDotenv({ providerName: PROVIDER_NAME }); + + expect(result).toStrictEqual({ + content: `# Set your OneSDK token to monitor your usage out-of-the-box. Get yours at https://superface.ai +SUPERFACE_ONESDK_TOKEN= +`, + newEmptyEnvVariables: ['SUPERFACE_ONESDK_TOKEN'], + }); + }); + + it('creates valid .env when valid token but no parameters or security schemes are given', () => { + const result = createNewDotenv({ + providerName: PROVIDER_NAME, + token: TOKEN, + }); + + expect(result).toStrictEqual({ + content: `# The token for monitoring your Comlinks at https://superface.ai +SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e +`, + newEmptyEnvVariables: [], + }); + }); + + it('creates valid .env when 2 parameters but no security schemes are given', () => { + const result = createNewDotenv({ + providerName: PROVIDER_NAME, + parameters: [PARAMETER, PARAMETER_WITH_DEFAULT], + token: TOKEN, + }); + + expect(result).toStrictEqual({ + content: `# The token for monitoring your Comlinks at https://superface.ai +SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e + +# Parameter description +MY_PROVIDER_PARAM_ONE= + +# Deployment zone +# for AWS +MY_PROVIDER_PARAM_TWO=us-west-1 +`, + newEmptyEnvVariables: ['MY_PROVIDER_PARAM_ONE'], + }); + }); + + it('creates valid .env when 2 parameters and 2 security schemes are given', () => { + const result = createNewDotenv({ + providerName: PROVIDER_NAME, + parameters: [PARAMETER, PARAMETER_WITH_DEFAULT], + security: [BASIC_AUTH, BEARER_AUTH], + token: TOKEN, + }); + + expect(result).toStrictEqual({ + content: `# The token for monitoring your Comlinks at https://superface.ai +SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e + +# Parameter description +MY_PROVIDER_PARAM_ONE= + +# Deployment zone +# for AWS +MY_PROVIDER_PARAM_TWO=us-west-1 +MY_PROVIDER_USERNAME= +MY_PROVIDER_PASSWORD= +MY_PROVIDER_TOKEN= +`, + newEmptyEnvVariables: [ + 'MY_PROVIDER_PARAM_ONE', + 'MY_PROVIDER_USERNAME', + 'MY_PROVIDER_PASSWORD', + 'MY_PROVIDER_TOKEN', + ], + }); + }); + }); + + describe('when there is a previous existing .env', () => { + it('creates valid .env when no token, no parameters or security schemes are given', () => { + const result = createNewDotenv({ + previousDotenv: EXISTING_DOTENV, + providerName: PROVIDER_NAME, + }); + + expect(result).toStrictEqual({ + content: EXISTING_DOTENV, + newEmptyEnvVariables: [], + }); + }); + + it('creates valid .env when 2 parameters but no security schemes are given', () => { + const result = createNewDotenv({ + previousDotenv: EXISTING_DOTENV, + providerName: PROVIDER_NAME, + parameters: [PARAMETER, PARAMETER_WITH_DEFAULT], + }); + + expect(result).toStrictEqual({ + content: `SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e +# Deployment zone +# for AWS +MY_PROVIDER_PARAM_TWO=us-west-1 +MY_PROVIDER_TOKEN= + +# Parameter description +MY_PROVIDER_PARAM_ONE= +`, + newEmptyEnvVariables: ['MY_PROVIDER_PARAM_ONE'], + }); + }); + + it('creates valid .env when 2 parameters and 2 security schemes are given', () => { + const result = createNewDotenv({ + previousDotenv: EXISTING_DOTENV, + providerName: PROVIDER_NAME, + parameters: [PARAMETER, PARAMETER_WITH_DEFAULT], + security: [BASIC_AUTH, BEARER_AUTH], + }); + + expect(result).toStrictEqual({ + content: `SUPERFACE_ONESDK_TOKEN=sfs_b31314b7fc8...8ec1930e +# Deployment zone +# for AWS +MY_PROVIDER_PARAM_TWO=us-west-1 +MY_PROVIDER_TOKEN= + +# Parameter description +MY_PROVIDER_PARAM_ONE= +MY_PROVIDER_USERNAME= +MY_PROVIDER_PASSWORD= +`, + newEmptyEnvVariables: [ + 'MY_PROVIDER_PARAM_ONE', + 'MY_PROVIDER_USERNAME', + 'MY_PROVIDER_PASSWORD', + ], + }); + }); + }); +}); diff --git a/src/logic/application-code/dotenv/dotenv.ts b/src/logic/application-code/dotenv/dotenv.ts new file mode 100644 index 00000000..01864bca --- /dev/null +++ b/src/logic/application-code/dotenv/dotenv.ts @@ -0,0 +1,135 @@ +import type { IntegrationParameter, SecurityScheme } from '@superfaceai/ast'; +import { + prepareProviderParameters, + prepareSecurityValues, +} from '@superfaceai/ast'; + +import { + ONESDK_TOKEN_COMMENT, + ONESDK_TOKEN_ENV, + ONESDK_TOKEN_UNAVAILABLE_COMMENT, +} from './onesdk-token'; + +export type NewDotenv = { + content: string; + newEmptyEnvVariables: string[]; +}; + +type EnvVar = { + name: string; + value: string | undefined; + comment: string | undefined; +}; + +export function createNewDotenv({ + previousDotenv, + providerName, + parameters, + security, + token, +}: { + previousDotenv?: string; + providerName: string; + parameters?: IntegrationParameter[]; + security?: SecurityScheme[]; + token?: string | null; +}): NewDotenv { + const previousContent = previousDotenv ?? ''; + + const parameterEnvs = getParameterEnvs(providerName, parameters); + const securityEnvs = getSecurityEnvs(providerName, security); + const tokenEnv = makeTokenEnv(token); + + const allEnvVariables = [tokenEnv, ...parameterEnvs, ...securityEnvs]; + + const newEnvsOnly = makeFilterForNewEnvs(previousContent); + + const newEnvVariables = allEnvVariables.filter(newEnvsOnly); + + return { + content: serializeContent(previousContent, newEnvVariables), + newEmptyEnvVariables: newEnvVariables + .filter(e => e.value === undefined) + .map(e => e.name), + }; +} + +function makeTokenEnv(token?: string | null): EnvVar { + return { + name: ONESDK_TOKEN_ENV, + value: token ?? undefined, + comment: + token !== undefined && token !== null + ? ONESDK_TOKEN_COMMENT + : ONESDK_TOKEN_UNAVAILABLE_COMMENT, + }; +} + +function makeFilterForNewEnvs(content: string): (e: EnvVar) => boolean { + const existingEnvs = new Set( + content + .split('\n') + .map(line => line.match(/^(\w+)=/)?.[1]) + .filter((s: string | undefined): s is string => Boolean(s)) + .map(t => t.toLowerCase()) + ); + + // returns true for envs that are NOT present in the `content` + return env => { + return !existingEnvs.has(env.name.toLowerCase()); + }; +} + +function serializeContent(previousContent: string, newEnvs: EnvVar[]): string { + const newEnvContent = newEnvs.map(serializeEnvVar).join('\n').trim(); + + const newContent = [previousContent, newEnvContent] + .filter(Boolean) + .join('\n'); + + return newEnvContent ? newContent + '\n' : newContent; +} + +function serializeEnvVar(env: EnvVar): string { + const comment = + env.comment !== undefined + ? '\n' + + env.comment + .split('\n') + .map(commentLine => `# ${commentLine}`) + .join('\n') + : ''; + + return `${comment ? comment + '\n' : ''}${env.name}=${env.value ?? ''}`; +} + +function getParameterEnvs( + providerName: string, + parameters?: IntegrationParameter[] +): EnvVar[] { + const params = parameters || []; + + const parameterEnvs = prepareProviderParameters(providerName, params); + + return params.map(param => ({ + name: removeDollarSign(parameterEnvs[param.name]), + value: param.default ?? undefined, + comment: param.description ?? undefined, + })); +} + +function getSecurityEnvs( + providerName: string, + security?: SecurityScheme[] +): EnvVar[] { + const securityValues = prepareSecurityValues(providerName, security || []); + + return securityValues + .map(({ id: _, ...securityValue }) => securityValue) + .flatMap(securityValue => Object.values(securityValue) as string[]) + .map(removeDollarSign) + .map(name => ({ name, value: undefined, comment: undefined })); +} + +const removeDollarSign = (text: string): string => + text.startsWith('$') ? text.slice(1) : text; diff --git a/src/logic/application-code/dotenv/index.ts b/src/logic/application-code/dotenv/index.ts new file mode 100644 index 00000000..2b87a942 --- /dev/null +++ b/src/logic/application-code/dotenv/index.ts @@ -0,0 +1,2 @@ +export * from './dotenv'; +export * from './onesdk-token'; diff --git a/src/logic/application-code/dotenv/onesdk-token.ts b/src/logic/application-code/dotenv/onesdk-token.ts new file mode 100644 index 00000000..017944b4 --- /dev/null +++ b/src/logic/application-code/dotenv/onesdk-token.ts @@ -0,0 +1,5 @@ +export const ONESDK_TOKEN_ENV = 'SUPERFACE_ONESDK_TOKEN'; +export const ONESDK_TOKEN_COMMENT = + 'The token for monitoring your Comlinks at https://superface.ai'; +export const ONESDK_TOKEN_UNAVAILABLE_COMMENT = + 'Set your OneSDK token to monitor your usage out-of-the-box. Get yours at https://superface.ai'; diff --git a/src/logic/application-code/index.ts b/src/logic/application-code/index.ts index 2136386c..f8062870 100644 --- a/src/logic/application-code/index.ts +++ b/src/logic/application-code/index.ts @@ -1 +1,2 @@ export * from './application-code'; +export * from './dotenv'; diff --git a/src/logic/application-code/input/prepare-usecase-input.test.ts b/src/logic/application-code/input/prepare-usecase-input.test.ts new file mode 100644 index 00000000..4851d1c6 --- /dev/null +++ b/src/logic/application-code/input/prepare-usecase-input.test.ts @@ -0,0 +1,115 @@ +import { parseProfile, Source } from '@superfaceai/parser'; + +import { SupportedLanguages } from '../application-code'; +import { prepareUseCaseInput } from './prepare-usecase-input'; + +describe('prepareUseCaseInput', () => { + const mockProfileSource = `name = "test" +version = "0.0.0" + +usecase Test safe { + + example InputExample { + input { + a = 'Luke', + b = 1.2, + c = true, + d = [1], + e = ['a', 'b'], + f = [true, false], + g = { a = 1, b = 2 }, + h = { a = 'a', b = 'b' }, + i = { a = true, b = false }, + k = { a = [{ b = [ true]}] }, + } + } +}`; + + const ast = parseProfile(new Source(mockProfileSource, 'test.supr')); + + describe('for js', () => { + it('should prepare input for use case', () => { + const input = prepareUseCaseInput(ast, SupportedLanguages.JS); + expect(input).toEqual(`{ + a : 'Luke', + b: 1.2, + c: true, + d: [ + 1, + ], + e: [ + 'a', + 'b', + ], + f: [ + true, + false, + ], + g: { + a: 1, + b: 2, + }, + h: { + a : 'a', + b : 'b', + }, + i: { + a: true, + b: false, + }, + k: { + a: [ + { + b: [ + true, + ], + }, + ], + }, + }`); + }); + }); + + describe('for python', () => { + it('should prepare input for use case', () => { + const input = prepareUseCaseInput(ast, SupportedLanguages.PYTHON); + expect(input).toEqual(`{ + "a" : 'Luke', + "b": 1.2, + "c": True, + "d": [ + 1, + ], + "e": [ + 'a', + 'b', + ], + "f": [ + True, + False, + ], + "g": { + "a": 1, + "b": 2, + }, + "h": { + "a" : 'a', + "b" : 'b', + }, + "i": { + "a": True, + "b": False, + }, + "k": { + "a": [ + { + "b": [ + True, + ], + }, + ], + }, + }`); + }); + }); +}); diff --git a/src/logic/application-code/input/prepare-usecase-input.ts b/src/logic/application-code/input/prepare-usecase-input.ts index e4531bae..50ea4694 100644 --- a/src/logic/application-code/input/prepare-usecase-input.ts +++ b/src/logic/application-code/input/prepare-usecase-input.ts @@ -5,12 +5,16 @@ import type { UseCaseDefinitionNode, } from '@superfaceai/ast'; +import type { SupportedLanguages } from '../application-code'; import { buildUseCaseExamples } from './example/build'; import type { UseCaseExample } from './example/usecase-example'; import INPUT_TEMPLATE from './templates'; import { makeRenderer } from './templates/template-renderer'; -export function prepareUseCaseInput(ast: ProfileDocumentNode): string { +export function prepareUseCaseInput( + ast: ProfileDocumentNode, + language: SupportedLanguages +): string { const namedModelDefinitionsCache: { [key: string]: NamedModelDefinitionNode; } = {}; @@ -55,9 +59,16 @@ export function prepareUseCaseInput(ast: ProfileDocumentNode): string { successExamples.push(...successExamplesForUseCase); }); + // const QUOTES_MAP: { + // [key in SupportedLanguages]: string; + // } = { + // js: '', + // python: '"', + // }; + const inputExample = successExamples.find(e => e.input !== undefined)?.input; const render = makeRenderer(INPUT_TEMPLATE, 'Input'); - return render({ input: inputExample }); + return render({ input: inputExample, language }); } diff --git a/src/logic/application-code/input/templates/array.ts b/src/logic/application-code/input/templates/array.ts index da372a31..9384191b 100644 --- a/src/logic/application-code/input/templates/array.ts +++ b/src/logic/application-code/input/templates/array.ts @@ -1 +1 @@ -export default '[{{#each items}}{{newLine (inc ../indent 2) }}{{#ifeq kind "string"}}{{>String }},{{/ifeq}}{{#ifeq kind "number"}}{{>Number }},{{/ifeq}}{{#ifeq kind "boolean"}}{{>Boolean }},{{/ifeq}}{{#ifeq kind "object"}}{{>Object use=":" indent=(inc ../indent 2) }},{{/ifeq}}{{#ifeq kind "array"}}{{>Array use=":" indent= (inc ../indent 2) }},{{/ifeq}}{{/each}}{{newLine indent}}]'; +export default '[{{#each items}}{{newLine (inc ../indent 2) }}{{#ifeq kind "string"}}{{>String }},{{/ifeq}}{{#ifeq kind "number"}}{{>Number }},{{/ifeq}}{{#ifeq kind "boolean"}}{{>Boolean language=../language }},{{/ifeq}}{{#ifeq kind "object"}}{{>Object use=":" indent=(inc ../indent 2) language=../language }},{{/ifeq}}{{#ifeq kind "array"}}{{>Array use=":" indent= (inc ../indent 2) language=../language }},{{/ifeq}}{{/each}}{{newLine indent}}]'; diff --git a/src/logic/application-code/input/templates/boolean.ts b/src/logic/application-code/input/templates/boolean.ts index 8630650f..110f902d 100644 --- a/src/logic/application-code/input/templates/boolean.ts +++ b/src/logic/application-code/input/templates/boolean.ts @@ -1 +1 @@ -export default '{{value}}'; +export default '{{booleanValue language value}}'; diff --git a/src/logic/application-code/input/templates/input.ts b/src/logic/application-code/input/templates/input.ts index 6fd2e3c7..d0f8b419 100644 --- a/src/logic/application-code/input/templates/input.ts +++ b/src/logic/application-code/input/templates/input.ts @@ -1 +1 @@ -export default '{{#if input}}{{#ifeq input.kind "object"}}{{>Object input use=":" indent=6}}{{/ifeq}}{{#ifeq input.kind "array"}}{{>Array input use=":" indent=6}}{{/ifeq}}{{/if}}'; +export default '{{#if input}}{{#ifeq input.kind "object"}}{{>Object input use=":" indent=6 language=language}}{{/ifeq}}{{#ifeq input.kind "array"}}{{>Array input use=":" indent=6 language=language}}{{/ifeq}}{{/if}}'; diff --git a/src/logic/application-code/input/templates/object.ts b/src/logic/application-code/input/templates/object.ts index b8539acb..bd6362b4 100644 --- a/src/logic/application-code/input/templates/object.ts +++ b/src/logic/application-code/input/templates/object.ts @@ -1 +1 @@ -export default '{{openObject}}{{#each properties}}{{#ifeq kind "string"}}{{newLine (inc ../indent 2) }}{{name}} {{../use}} {{>String }},{{/ifeq}}{{#ifeq kind "number"}}{{newLine (inc ../indent 2) }}{{name}}{{../use}} {{>Number }},{{/ifeq}}{{#ifeq kind "boolean"}}{{newLine (inc ../indent 2) }}{{name}}{{../use}} {{>Boolean }},{{/ifeq}}{{#ifeq kind "object"}}{{newLine (inc ../indent 2) }}{{name}}{{../use}} {{>Object use=":" indent= (inc ../indent 2) }},{{/ifeq}}{{#ifeq kind "array"}}{{newLine (inc ../indent 2) }}{{name}}{{../use}} {{>Array use=":" indent= (inc ../indent 2) }},{{/ifeq}}{{/each}}{{newLine indent}}{{closeObject}}'; +export default '{{openObject}}{{#each properties}}{{#ifeq kind "string"}}{{newLine (inc ../indent 2) }}{{quotes ../language}}{{name}}{{quotes ../language}} {{../use}} {{>String }},{{/ifeq}}{{#ifeq kind "number"}}{{newLine (inc ../indent 2) }}{{quotes ../language}}{{name}}{{quotes ../language}}{{../use}} {{>Number }},{{/ifeq}}{{#ifeq kind "boolean"}}{{newLine (inc ../indent 2) }}{{quotes ../language}}{{name}}{{quotes ../language}}{{../use}} {{>Boolean language=../language }},{{/ifeq}}{{#ifeq kind "object"}}{{newLine (inc ../indent 2) }}{{quotes ../language}}{{name}}{{quotes ../language}}{{../use}} {{>Object use=":" indent= (inc ../indent 2) language=../language }},{{/ifeq}}{{#ifeq kind "array"}}{{newLine (inc ../indent 2) }}{{quotes ../language}}{{name}}{{quotes ../language}}{{../use}} {{>Array use=":" indent= (inc ../indent 2) language=../language}},{{/ifeq}}{{/each}}{{newLine indent}}{{closeObject}}'; diff --git a/src/logic/application-code/input/templates/template-renderer/helpers.ts b/src/logic/application-code/input/templates/template-renderer/helpers.ts index e39f852b..39076cc8 100644 --- a/src/logic/application-code/input/templates/template-renderer/helpers.ts +++ b/src/logic/application-code/input/templates/template-renderer/helpers.ts @@ -1,4 +1,7 @@ /* eslint-disable */ + +import { SupportedLanguages } from '../../../application-code'; + //TODO: add types export const HELPERS = [ { @@ -86,6 +89,25 @@ export const HELPERS = [ return parseInt(value) + amount; }, }, + { + name: 'quotes', + helper: function (language: string) { + if (language === SupportedLanguages.PYTHON) { + return `"`; + } + return ''; + }, + }, + + { + name: 'booleanValue', + helper: function (language: string, value: boolean) { + if (language === SupportedLanguages.PYTHON) { + return value ? 'True' : 'False'; + } + return value; + }, + }, { name: 'openObject', diff --git a/src/logic/application-code/js/application-code.test.ts b/src/logic/application-code/js/application-code.test.ts index 0427132c..6b8567e8 100644 --- a/src/logic/application-code/js/application-code.test.ts +++ b/src/logic/application-code/js/application-code.test.ts @@ -29,43 +29,46 @@ describe('jsApplicationCode', () => { expect(result).toEqual({ code: `import { config } from 'dotenv'; // Load OneClient from SDK -import { OneClient } from '@superfaceai/one-sdk/node/index.js'; +import { OneClient, PerformError, UnexpectedError } from '@superfaceai/one-sdk/node/index.js'; // Load environment variables from .env file config(); -async function main() { - const client = new OneClient({ - // Optionally you can use your OneSDK token to monitor your usage. Get one at https://superface.ai/app - // token: - // Specify path to assets folder - assetsPath: '${buildSuperfaceDirPath()}' - }); - // Load profile and use case - const profile = await client.getProfile('${scope}/${name}'); - const useCase = profile.getUseCase('${useCaseName}') +const client = new OneClient({ + // The token for monitoring your Comlinks at https://superface.ai + token: process.env.SUPERFACE_ONESDK_TOKEN, + // Path to Comlinks within your project + assetsPath: '${buildSuperfaceDirPath()}' +}); - try { - // Execute use case - const result = await useCase.perform( - // Use case input - {}, - { - provider: '${provider}', - parameters: {}, - // Security values for provider - security: {} - } - ); +// Load Comlink profile and use case +const profile = await client.getProfile('${scope}/${name}'); +const useCase = profile.getUseCase('${useCaseName}') - console.log("RESULT:", JSON.stringify(result, null, 2)); +try { + // Execute use case + const result = await useCase.perform( + // Use case input + {}, + { + provider: '${provider}', + parameters: {}, + // Security values for provider + security: {} + } + ); - } catch (e) { - console.log("ERROR:", JSON.stringify(e, null, 2)); + console.log("RESULT:", JSON.stringify(result, null, 2)); +} catch (e) { + if (e instanceof PerformError) { + console.log('ERROR RESULT:', e.errorResult); + } else if (e instanceof UnexpectedError) { + console.error('ERROR:', e); + } else { + throw e; } } - -void main();`, +`, requiredParameters: [], requiredSecurity: [], }); diff --git a/src/logic/application-code/js/application-code.ts b/src/logic/application-code/js/application-code.ts index ffb8df61..a0757374 100644 --- a/src/logic/application-code/js/application-code.ts +++ b/src/logic/application-code/js/application-code.ts @@ -3,6 +3,7 @@ import type { IntegrationParameter, SecurityScheme } from '@superfaceai/ast'; import { buildSuperfaceDirPath } from '../../../common/file-structure'; import { ProfileId } from '../../../common/profile'; import type { ApplicationCodeWriter } from '../application-code'; +import { ONESDK_TOKEN_COMMENT, ONESDK_TOKEN_ENV } from '../dotenv'; import { prepareParameters } from './parameters'; import { prepareSecurity } from './security'; @@ -25,7 +26,6 @@ export const jsApplicationCode: ApplicationCodeWriter = ({ parameters?: IntegrationParameter[]; security?: SecurityScheme[]; }) => { - // TODO: revisit this const pathToSdk = '@superfaceai/one-sdk/node/index.js'; const profileId = ProfileId.fromScopeName(profile.scope, profile.name).id; @@ -35,43 +35,46 @@ export const jsApplicationCode: ApplicationCodeWriter = ({ const code = `import { config } from 'dotenv'; // Load OneClient from SDK -import { OneClient } from '${pathToSdk}'; +import { OneClient, PerformError, UnexpectedError } from '${pathToSdk}'; // Load environment variables from .env file config(); -async function main() { - const client = new OneClient({ - // Optionally you can use your OneSDK token to monitor your usage. Get one at https://superface.ai/app - // token: - // Specify path to assets folder - assetsPath: '${buildSuperfaceDirPath()}' - }); - // Load profile and use case - const profile = await client.getProfile('${profileId}'); - const useCase = profile.getUseCase('${useCaseName}') +const client = new OneClient({ + // ${ONESDK_TOKEN_COMMENT} + token: process.env.${ONESDK_TOKEN_ENV}, + // Path to Comlinks within your project + assetsPath: '${buildSuperfaceDirPath()}' +}); - try { - // Execute use case - const result = await useCase.perform( - // Use case input - ${input}, - { - provider: '${provider}', - parameters: ${preparedParameters.parametersString}, - // Security values for provider - security: ${preparedSecurity.securityString} - } - ); +// Load Comlink profile and use case +const profile = await client.getProfile('${profileId}'); +const useCase = profile.getUseCase('${useCaseName}') - console.log("RESULT:", JSON.stringify(result, null, 2)); +try { + // Execute use case + const result = await useCase.perform( + // Use case input + ${input}, + { + provider: '${provider}', + parameters: ${preparedParameters.parametersString}, + // Security values for provider + security: ${preparedSecurity.securityString} + } + ); - } catch (e) { - console.log("ERROR:", JSON.stringify(e, null, 2)); + console.log("RESULT:", JSON.stringify(result, null, 2)); +} catch (e) { + if (e instanceof PerformError) { + console.log('ERROR RESULT:', e.errorResult); + } else if (e instanceof UnexpectedError) { + console.error('ERROR:', e); + } else { + throw e; } } - -void main();`; +`; return { code, diff --git a/src/logic/application-code/python/application-code.test.ts b/src/logic/application-code/python/application-code.test.ts index 242f9424..063c30d6 100644 --- a/src/logic/application-code/python/application-code.test.ts +++ b/src/logic/application-code/python/application-code.test.ts @@ -29,19 +29,22 @@ describe('pythonApplicationCode', () => { expect(result).toEqual({ code: `import os from dotenv import load_dotenv -from superfaceai.one_sdk import OneClient +import sys +from one_sdk import OneClient, PerformError, UnexpectedError load_dotenv() client = OneClient( - # Optionally you can use your OneSDK token to monitor your usage. Get one at https://superface.ai/app - # token = - # Specify path to assets folder + # The token for monitoring your Comlinks at https://superface.ai + token = os.getenv("SUPERFACE_ONESDK_TOKEN"), + # Path to Comlinks within your project assets_path = "${buildSuperfaceDirPath()}" ) +# Load Comlink profile and use case profile = client.get_profile("${scope}/${name}") use_case = profile.get_usecase("${useCaseName}") + try: result = use_case.perform( {}, @@ -51,7 +54,14 @@ try: ) print(f"RESULT: {result}") except Exception as e: - print(f"ERROR: {e}")`, + if isinstance(e, PerformError): + print(f"ERROR RESULT: {e.error_result}") + elif isinstance(e, UnexpectedError): + print(f"ERROR:", e, file=sys.stderr) + else: + raise e +finally: + client.send_metrics_to_superface()`, requiredParameters: [], requiredSecurity: [], }); diff --git a/src/logic/application-code/python/application-code.ts b/src/logic/application-code/python/application-code.ts index 9e0b2254..d9dcda83 100644 --- a/src/logic/application-code/python/application-code.ts +++ b/src/logic/application-code/python/application-code.ts @@ -3,8 +3,9 @@ import type { IntegrationParameter, SecurityScheme } from '@superfaceai/ast'; import { buildSuperfaceDirPath } from '../../../common/file-structure'; import { ProfileId } from '../../../common/profile'; import type { ApplicationCodeWriter } from '../application-code'; -import { prepareParameters } from '../js/parameters'; -import { prepareSecurity } from '../js/security'; +import { ONESDK_TOKEN_COMMENT, ONESDK_TOKEN_ENV } from '../dotenv'; +import { prepareParameters } from './parameters'; +import { prepareSecurity } from './security'; export const pythonApplicationCode: ApplicationCodeWriter = ({ profile, @@ -31,19 +32,22 @@ export const pythonApplicationCode: ApplicationCodeWriter = ({ const code = `import os from dotenv import load_dotenv -from superfaceai.one_sdk import OneClient +import sys +from one_sdk import OneClient, PerformError, UnexpectedError load_dotenv() client = OneClient( - # Optionally you can use your OneSDK token to monitor your usage. Get one at https://superface.ai/app - # token = - # Specify path to assets folder + # ${ONESDK_TOKEN_COMMENT} + token = os.getenv("${ONESDK_TOKEN_ENV}"), + # Path to Comlinks within your project assets_path = "${buildSuperfaceDirPath()}" ) +# Load Comlink profile and use case profile = client.get_profile("${profileId}") use_case = profile.get_usecase("${useCaseName}") + try: result = use_case.perform( ${input}, @@ -53,7 +57,14 @@ try: ) print(f"RESULT: {result}") except Exception as e: - print(f"ERROR: {e}")`; + if isinstance(e, PerformError): + print(f"ERROR RESULT: {e.error_result}") + elif isinstance(e, UnexpectedError): + print(f"ERROR:", e, file=sys.stderr) + else: + raise e +finally: + client.send_metrics_to_superface()`; return { code, diff --git a/src/logic/execution/execute.ts b/src/logic/execution/execute.ts index c73b2a62..ddecf0af 100644 --- a/src/logic/execution/execute.ts +++ b/src/logic/execution/execute.ts @@ -29,10 +29,20 @@ export async function execute( function prepareCommand( file: string, - _language: SupportedLanguages + language: SupportedLanguages ): { command: string; args: string[] } { - return { - command: 'node', - args: ['--experimental-wasi-unstable-preview1', file], + const COMMAND_MAP: { + [key in SupportedLanguages]: { command: string; args: string[] }; + } = { + js: { + command: 'node', + args: ['--no-warnings', '--experimental-wasi-unstable-preview1', file], + }, + python: { + command: 'python3', + args: [file], + }, }; + + return COMMAND_MAP[language]; } diff --git a/src/logic/map.ts b/src/logic/map.ts index 6fd356b9..2bf31b6a 100644 --- a/src/logic/map.ts +++ b/src/logic/map.ts @@ -80,8 +80,9 @@ async function startMapPreparation( }, { client, userError }: { client: ServiceClient; userError: UserError } ): Promise { - const profileId = `${profile.scope !== undefined ? profile.scope + '.' : ''}${profile.name - }`; + const profileId = `${profile.scope !== undefined ? profile.scope + '.' : ''}${ + profile.name + }`; const jobUrlResponse = await client.fetch( `/authoring/profiles/${profileId}/maps`, { @@ -128,7 +129,7 @@ async function startMapPreparation( async function finishMapPreparation( resultUrl: string, - { client, userError }: { client: ServiceClient, userError: UserError } + { client, userError }: { client: ServiceClient; userError: UserError } ): Promise { const resultResponse = await client.fetch(resultUrl, { method: 'GET', @@ -140,7 +141,10 @@ async function finishMapPreparation( }); if (resultResponse.status !== 200) { - throw userError(`Unexpected status code ${resultResponse.status} received`, 1); + throw userError( + `Unexpected status code ${resultResponse.status} received`, + 1 + ); } const body = (await resultResponse.json()) as unknown; diff --git a/src/logic/new.ts b/src/logic/new.ts index c26caa08..87d35499 100644 --- a/src/logic/new.ts +++ b/src/logic/new.ts @@ -34,7 +34,8 @@ function assertProfileResponse( tmp, null, 2 - )}`, 1 + )}`, + 1 ); } @@ -148,7 +149,7 @@ async function startProfilePreparation( async function finishProfilePreparation( resultUrl: string, - { client, userError }: { client: ServiceClient, userError: UserError } + { client, userError }: { client: ServiceClient; userError: UserError } ): Promise { const resultResponse = await client.fetch(resultUrl, { method: 'GET', @@ -160,7 +161,10 @@ async function finishProfilePreparation( }); if (resultResponse.status !== 200) { - throw userError(`Unexpected status code ${resultResponse.status} received`, 1); + throw userError( + `Unexpected status code ${resultResponse.status} received`, + 1 + ); } const body = (await resultResponse.json()) as unknown; diff --git a/src/logic/project/js/js.test.ts b/src/logic/project/js/js.test.ts index dbb788ef..b235cb15 100644 --- a/src/logic/project/js/js.test.ts +++ b/src/logic/project/js/js.test.ts @@ -34,7 +34,8 @@ describe('prepareJsProject', () => { prepareJsProject('3.0.0-alpha.12', '^16.0.3') ).resolves.toEqual({ saved: true, - installationGuide: expect.any(String), + dependencyInstallCommand: expect.any(String), + languageDependency: expect.any(String), path: expect.stringContaining('superface/package.json'), }); @@ -51,7 +52,8 @@ describe('prepareJsProject', () => { prepareJsProject('3.0.0-alpha.12', '^16.0.3') ).resolves.toEqual({ saved: false, - installationGuide: expect.any(String), + dependencyInstallCommand: expect.any(String), + languageDependency: expect.any(String), path: expect.stringContaining('superface/package.json'), }); diff --git a/src/logic/project/js/js.ts b/src/logic/project/js/js.ts index f543b9f0..391a52af 100644 --- a/src/logic/project/js/js.ts +++ b/src/logic/project/js/js.ts @@ -4,11 +4,13 @@ import { OutputStream } from '../../../common/output-stream'; import { SupportedLanguages } from '../../application-code'; export async function prepareJsProject( - sdkVerion = '3.0.0-alpha.12', + // https://www.npmjs.com/package/@superfaceai/one-sdk?activeTab=versions + sdkVersion = 'beta', // get latest beta using the `beta` tag dotenvVersion = '^16.0.3' ): Promise<{ saved: boolean; - installationGuide: string; + dependencyInstallCommand: string; + languageDependency: string; path: string; }> { const packageJson = `{ @@ -23,20 +25,31 @@ export async function prepareJsProject( "author": "", "license": "ISC", "dependencies": { - "@superfaceai/one-sdk": "${sdkVerion}", + "@superfaceai/one-sdk": "${sdkVersion}", "dotenv": "${dotenvVersion}" } }`; const packageJsonPath = buildProjectDefinitionFilePath(SupportedLanguages.JS); - const installationGuide = `You need to have Node version 18.0.0 or higher installed to run the integration.\nYou can install defined dependencies by running \`npm install\` in \`superface\` directory.`; + const languageDependency = 'Node.js > 18.0.0'; + const dependencyInstallCommand = 'npm install'; if (!(await exists(packageJsonPath))) { await OutputStream.writeOnce(packageJsonPath, packageJson); - return { saved: true, installationGuide, path: packageJsonPath }; + return { + saved: true, + dependencyInstallCommand, + languageDependency, + path: packageJsonPath, + }; } - return { saved: false, installationGuide, path: packageJsonPath }; + return { + saved: false, + dependencyInstallCommand, + languageDependency, + path: packageJsonPath, + }; } diff --git a/src/logic/project/prepare-project.ts b/src/logic/project/prepare-project.ts index a738428e..9159a519 100644 --- a/src/logic/project/prepare-project.ts +++ b/src/logic/project/prepare-project.ts @@ -4,13 +4,15 @@ import { preparePythonProject } from './python'; export async function prepareProject(language: SupportedLanguages): Promise<{ saved: boolean; - installationGuide: string; + dependencyInstallCommand: string; + languageDependency: string; path: string; }> { const PROJECT_PREPARATION_MAP: { [key in SupportedLanguages]: () => Promise<{ saved: boolean; - installationGuide: string; + dependencyInstallCommand: string; + languageDependency: string; path: string; }>; } = { diff --git a/src/logic/project/python/python.test.ts b/src/logic/project/python/python.test.ts index 841fe1ce..a63b4ca3 100644 --- a/src/logic/project/python/python.test.ts +++ b/src/logic/project/python/python.test.ts @@ -30,9 +30,10 @@ describe('preparePythonProject', () => { it('creates package.json if it does not exist', async () => { jest.mocked(exists).mockResolvedValueOnce(false); - await expect(preparePythonProject('3.0.0-alpha.12')).resolves.toEqual({ + await expect(preparePythonProject('1.0.0b1')).resolves.toEqual({ saved: true, - installationGuide: expect.any(String), + dependencyInstallCommand: expect.any(String), + languageDependency: expect.any(String), path: expect.stringContaining('superface/requirements.txt'), }); @@ -45,9 +46,10 @@ describe('preparePythonProject', () => { it('does not create package.json if it exists', async () => { jest.mocked(exists).mockResolvedValueOnce(true); - await expect(preparePythonProject('3.0.0-alpha.12')).resolves.toEqual({ + await expect(preparePythonProject('1.0.0b1')).resolves.toEqual({ saved: false, - installationGuide: expect.any(String), + dependencyInstallCommand: expect.any(String), + languageDependency: expect.any(String), path: expect.stringContaining('superface/requirements.txt'), }); diff --git a/src/logic/project/python/python.ts b/src/logic/project/python/python.ts index 8bb2d37d..77a4cbb4 100644 --- a/src/logic/project/python/python.ts +++ b/src/logic/project/python/python.ts @@ -4,32 +4,44 @@ import { OutputStream } from '../../../common/output-stream'; import { SupportedLanguages } from '../../application-code'; export async function preparePythonProject( - sdkVerion = '3.0.0-alpha.12' + sdkVersion = '1b' // beta on major 1 ): Promise<{ saved: boolean; - installationGuide: string; + dependencyInstallCommand: string; + languageDependency: string; path: string; }> { - // TODO: revisit when SDK supports python - const requirements = `Brotli==1.0.9 + const requirements = `one-sdk>=${sdkVersion} +python-dotenv==1.0.0 +Brotli==1.0.9 certifi==2023.5.7 charset-normalizer==3.1.0 -@superfaceai/one-sdk==${sdkVerion} idna==3.4 urllib3==2.0.3 -wasmtime==9.0.0`; +wasmtime==10.0.0`; const requirementsPath = buildProjectDefinitionFilePath( SupportedLanguages.PYTHON ); - const installationGuide = `You need to have Python version 3.11 or higher installed to run the integration. You can check used dependencies in: ${requirementsPath}\nYou can install defined dependencies by running \`python3 -m pip install -r requirements.txt\` in \`superface\` directory.`; + const languageDependency = 'Python >= 3.8'; + const dependencyInstallCommand = 'python3 -m pip install -r requirements.txt'; if (!(await exists(requirementsPath))) { await OutputStream.writeOnce(requirementsPath, requirements); - return { saved: true, installationGuide, path: requirementsPath }; + return { + saved: true, + languageDependency, + dependencyInstallCommand, + path: requirementsPath, + }; } - return { saved: false, installationGuide, path: requirementsPath }; + return { + saved: false, + languageDependency, + dependencyInstallCommand, + path: requirementsPath, + }; }