diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index a6695e4c2..eeda9f863 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -22,7 +22,7 @@ export type CommandList = | 'docgen' | 'docs' | 'execute' - | 'generate' + | 'generate-adaptor' | 'metadata' | 'pull' | 'repo-clean' diff --git a/packages/cli/src/generate/adaptor.ts b/packages/cli/src/generate/adaptor.ts index 89629065c..2b876d775 100644 --- a/packages/cli/src/generate/adaptor.ts +++ b/packages/cli/src/generate/adaptor.ts @@ -1,10 +1,9 @@ /** * Handler to generate adaptor code */ - import { Opts } from '../options'; import { Logger } from '../util'; -import mailchimp from './mailchimp-spec.json' assert { type: 'json' }; +import loadGenSpec from './adaptor/load-gen-spec'; // TODO: really I just want one domain here const endpoints = { @@ -12,7 +11,7 @@ const endpoints = { code: 'http://localhost:8002/generate_code/', }; -type AdaptorGenOptions = Pick< +export type AdaptorGenOptions = Pick< Opts, | 'command' | 'path' // path to spec - we proably want to override the description @@ -21,14 +20,17 @@ type AdaptorGenOptions = Pick< | 'monorepoPath' // maybe use the monorepo (or use env var) | 'outputPath' // where to output to. Defaults to monorepo or as sibling of the spec > & { - adaptor: string; + adaptor?: string; + spec?: string; // TODO spec overrides }; // spec.spec is silly, so what is this object? -type Spec = { - spec: any; // OpenAPI spec +export type Spec = { + adaptor?: string; // adaptor name. TOOD rename to name? + + spec: any; // OpenAPI spec. TODO rename to api? instruction: string; // for now... but we'll use endpoints later @@ -37,19 +39,16 @@ type Spec = { model?: string; // TODO not supported yet }; -const generateAdaptor = async (opts: any, logger: Logger) => { - logger.success('** GENERATE ADAPTOR**'); +const generateAdaptor = async (opts: AdaptorGenOptions, logger: Logger) => { + // Load the input spec from the cli options + const spec = await loadGenSpec(opts, logger); + + // TODO Validate that the spec looks correct + // if we're using the monorepo, and no adaptor with this name exists // prompt to generate it // humm is that worth it? it'll create a git diff anyway - // TODO load spec from path - // gonna hard code it right now - const spec = { - spec: mailchimp, // post as open_api_spec - instruction: 'Create an OpenFn function that accesses the /goals endpoint', - }; - const sig = await generateSignature(spec, logger); const code = await generateCode(spec, sig, logger); @@ -77,6 +76,8 @@ const convertSpec = (spec: Spec) => JSON.stringify({ open_api_spec: spec.spec, instruction: spec.instruction, + + // For now we force this model model: 'gpt3_turbo', }); diff --git a/packages/cli/src/generate/adaptor/load-gen-spec.ts b/packages/cli/src/generate/adaptor/load-gen-spec.ts new file mode 100644 index 000000000..1b62235dc --- /dev/null +++ b/packages/cli/src/generate/adaptor/load-gen-spec.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import type { AdaptorGenOptions, Spec } from '../adaptor'; +import { Logger, abort } from '../../util'; + +// single standalone function to parse all the options and return a spec object +// I can unit test this comprehensively you see +const loadGenSpec = async (opts: AdaptorGenOptions, logger: Logger) => { + let spec: Partial = {}; + + if (opts.path) { + const inputPath = path.resolve(opts.path); + logger.debug(`Loading input spec from ${inputPath}`); + + try { + const text = await fs.readFile(inputPath, 'utf8'); + spec = JSON.parse(text); + } catch (e) { + return abort( + logger, + 'spec load error', + undefined, + `Failed to load a codegen specc from ${inputPath}` + ); + } + } + + if (opts.spec) { + spec.spec = opts.spec; + } + if (opts.adaptor) { + spec.adaptor = opts.adaptor; + } + + if (typeof spec.spec === 'string') { + // TODO if opts.path isn't set I think this will blow up + const specPath = path.resolve(path.dirname(opts.path ?? '.'), spec.spec); + logger.debug(`Loading OpenAPI spec from ${specPath}`); + try { + const text = await fs.readFile(specPath, 'utf8'); + spec.spec = JSON.parse(text); + } catch (e) { + return abort( + logger, + 'OpenAPI error', + undefined, + `Failed to load openAPI spec from ${specPath}` + ); + } + } + + // if no name provided, see if we can pull one from the spec + if (!spec.adaptor) { + // TOOD use a lib for this? + spec.adaptor = spec.spec.info?.title?.toLowerCase().replace(/\W/g, '-'); + } + + logger.debug(`Final spec: ${JSON.stringify(spec, null, 2)}`); + + return spec as Required; +}; + +export default loadGenSpec; diff --git a/packages/cli/src/generate/command.ts b/packages/cli/src/generate/command.ts index e065f2c53..a40b4c7bf 100644 --- a/packages/cli/src/generate/command.ts +++ b/packages/cli/src/generate/command.ts @@ -16,7 +16,7 @@ options.push({ // Adaptor generation subcommand const adaptor = { - command: 'adaptor', + command: 'adaptor [path]', desc: 'Generate adaptor code', handler: ensure('generate-adaptor', options), builder: (yargs) => @@ -27,7 +27,6 @@ const adaptor = { ) .positional('path', { describe: 'The path spec.json', - demandOption: true, }), } as yargs.CommandModule<{}>; diff --git a/packages/cli/src/generate/mailchimp-spec.json b/packages/cli/src/generate/mailchimp-spec.json deleted file mode 100644 index fe94463a0..000000000 --- a/packages/cli/src/generate/mailchimp-spec.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Mailchimp API", - "version": "1.0.0" - }, - "paths": { - "/lists/{list_id}/members/{subscriber_hash}/goals": { - "get": { - "summary": "Get Member Goals", - "description": "Get information about recent goal events for a specific list member.", - "operationId": "getListMemberGoals", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - }, - "post": { - "summary": "Create Member Goal", - "description": "Create a new goal event for a specific list member.", - "operationId": "createListMemberGoal", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "example": {} - } - } - }, - "responses": { - "201": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - } - }, - "/lists/{list_id}/members/{subscriber_hash}/tags": { - "get": { - "summary": "Get Member Tags", - "description": "Get all the tags assigned to a contact.", - "operationId": "getListMemberTags", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - }, - "post": { - "summary": "Manage Member Tags", - "description": "Manage the tags assigned to a contact.", - "operationId": "manageListMemberTags", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "example": {} - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - } - }, - "/lists/{list_id}/members/{subscriber_hash}/events": { - "get": { - "summary": "Get Member Events", - "description": "Get website or in-app actions for a specific list member.", - "operationId": "getListMemberEvents", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - }, - "post": { - "summary": "Trigger Member Events", - "description": "Trigger targeted automations based on website or in-app actions.", - "operationId": "triggerListMemberEvents", - "parameters": [ - { - "name": "list_id", - "in": "path", - "required": true, - "description": "The ID of the list.", - "schema": { - "type": "string" - } - }, - { - "name": "subscriber_hash", - "in": "path", - "required": true, - "description": "The hash of the subscriber.", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "example": {} - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "example": {} - } - } - } - } - } - } - } -} diff --git a/packages/cli/src/util/index.ts b/packages/cli/src/util/index.ts index 640967359..af8b942c4 100644 --- a/packages/cli/src/util/index.ts +++ b/packages/cli/src/util/index.ts @@ -1,6 +1,7 @@ import expandAdaptors from './expand-adaptors'; import ensureLogOpts from './ensure-log-opts'; +import abort from './abort'; export * from './logger'; -export { expandAdaptors, ensureLogOpts }; +export { expandAdaptors, ensureLogOpts, abort }; diff --git a/packages/cli/test/generate/adaptor/load-gen-spec.test.ts b/packages/cli/test/generate/adaptor/load-gen-spec.test.ts new file mode 100644 index 000000000..854a028d9 --- /dev/null +++ b/packages/cli/test/generate/adaptor/load-gen-spec.test.ts @@ -0,0 +1,185 @@ +import test from 'ava'; +import { mockFs, resetMockFs } from '../../util'; +import loadGenSpec from '../../../src/generate/adaptor/load-gen-spec'; +import { createMockLogger } from '@openfn/logger'; + +const logger = createMockLogger(); + +test.after(resetMockFs); + +test.serial('should load a spec from a json file', async (t) => { + const spec = { + adaptor: 'a', + spec: {}, + instruction: 'abc', + }; + + // Note that this takes like 4 seconds to initialise?! + mockFs({ + 'spec.json': JSON.stringify(spec), + }); + + const options = { + path: 'spec.json', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, spec); +}); + +test.serial( + 'should load a spec from a json file with overriden name', + async (t) => { + const spec = { + adaptor: 'a', + spec: {}, + instruction: 'abc', + }; + + mockFs({ + 'spec.json': JSON.stringify(spec), + }); + + const options = { + path: 'spec.json', + adaptor: 'b', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + ...spec, + adaptor: 'b', + }); + } +); + +test.serial( + 'should load a spec from a json file with overridden api spec', + async (t) => { + const api = { x: 1 }; + + const spec = { + adaptor: 'a', + spec: {}, + instruction: 'abc', + }; + + mockFs({ + 'spec.json': JSON.stringify(spec), + }); + + const options = { + path: 'spec.json', + spec: api, + }; + + // @ts-ignore options typing + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + ...spec, + spec: api, + }); + } +); + +test.serial('should load an api spec from a path', async (t) => { + const api = { x: 1 }; + + const spec = { + adaptor: 'a', + spec: 'api.json', + instruction: 'abc', + }; + + mockFs({ + 'spec.json': JSON.stringify(spec), + 'api.json': JSON.stringify(api), + }); + + const options = { + path: 'spec.json', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + ...spec, + spec: api, + }); +}); + +test.serial('should load an api spec from a path override', async (t) => { + const api = { x: 1 }; + + const spec = { + adaptor: 'a', + spec: {}, + instruction: 'abc', + }; + + mockFs({ + 'spec.json': JSON.stringify(spec), + 'api.json': JSON.stringify(api), + }); + + const options = { + path: 'spec.json', + spec: 'api.json', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + ...spec, + spec: api, + }); +}); + +test.serial('should generate a default name from the API spec', async (t) => { + const spec = { + spec: { + info: { + title: 'my Friendly API', + }, + }, + instruction: 'abc', + }; + + mockFs({ + 'spec.json': JSON.stringify(spec), + }); + + const options = { + path: 'spec.json', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + ...spec, + adaptor: 'my-friendly-api', + }); +}); + +test.serial('should load an API spec CLI path and name', async (t) => { + const api = { x: 1 }; + + mockFs({ + 'api.json': JSON.stringify(api), + }); + + const options = { + spec: 'api.json', + adaptor: 'a', + }; + + const result = await loadGenSpec(options, logger); + + t.deepEqual(result, { + adaptor: 'a', + spec: api, + }); +});