Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/sc-123395/implement-create-command #691

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
109 changes: 109 additions & 0 deletions assets/logicFunction/@types/particle_core.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
declare module 'particle:core' {
namespace Particle {
/***********************************************LEDGER**************************************************************/
export const MERGE: "Merge";
export const REPLACE: "Replace";
export interface Ledger {
/**
* Gets the current value of the ledger.
* Empty ledger returns {}, error returns null
* @returns {Object} the current value of the ledger
* @throws {Error} if the ledger doesn't exist or there was an error getting the ledger instance
*/
get(): { updatedAt: string, data: Record<string, any> },

/**
* Sets the value of the ledger. If the ledger does not exist, it will be created.
*
* @param data The data you would like to put into the ledger
* @param setMode Whether to replace or merge the new data into the ledger. Defaults to Replace. You can use the constants MERGE and REPLACE here
* @throws {Error} if the ledger doesn't exist or there was an error setting the ledger instance
*/
set(data: Record<string, any>, setMode: "Replace" | "Merge"): null,

/**
* Deletes the ledger data
* @throws {Error} if there was an error deleting the ledger instance
*/
delete(): null
}
/**
* Gets a ledger object which allows you to store data in the cloud. The scope value will automatically be picked
* based on the trigger's context. If you want to use a different scope value, you can provide a deviceId or productId
* depending on your needs in the second param.
*
* If the trigger contains a deviceId and productId, we will pass those to ledger and use what the ledger definition requires
*
* @param name a unique name regardless of the definition's scope
* @param scopeValues the override scope values if you want to ignore the logic trigger or are using a scheduled logic trigger
*/
export function ledger(name: string, scopeValues?: { deviceId?: string, productId?: number }): Ledger;

/***********************************************CONTEXT*************************************************************/
export interface FunctionInfo {
ownerId: string;
logicFunctionId: string;
}

export interface TriggerInfo {
triggerEventId?: string;
triggeredAt: string;
}

export interface EventInfo {
publishedAt: string;
eventName: string;
eventData?: string;
deviceId: string;
productId?: number;
userId?: string;
}

export interface ScheduledInfo {
scheduledAt: string;
startAt: string;
endAt?: string;
}

export interface LedgerChangeInfo {
changedAt: string;
changeType: 'set' | 'delete';
name: string;
scope: string;
data: Record<string, any>
}

/**
* The context object that is passed to the function when it is invoked.
* The event, scheduled and ledgerChange properties will only be present if the function was invoked by that type of trigger
* and only one of them will be present at a time (logic functions can only have one type of trigger).
*/
export interface FunctionContext {
functionInfo: FunctionInfo;
trigger: TriggerInfo;
event?: EventInfo;
scheduled?: ScheduledInfo;
ledgerChange?: LedgerChangeInfo;
}

/**
* Returns an object containing the raw context, trigger and its details.
*/
export function getContext(): FunctionContext;

/***********************************************PUBLISH*************************************************************/
/**
* Synchronously emits a Particle event message. Note that non-string values for `data` will be stringified
*
* @param name The name of the event
* @param data The particle publish data
* @param options Allows specifying how the data is published
* @param options.productId Publish to the event stream of this product
* @param options.asDeviceId Publish the event as if it came from this device.
* @throws {Error} if there was an error publishing the event
*/
export function publish(name: string, data: any | undefined, options: { productId: number, asDeviceId: string }): null;
}

export = Particle;
}
42 changes: 42 additions & 0 deletions assets/logicFunction/@types/particle_encoding.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
declare module 'particle:encoding' {
namespace Encoding {
/**
* Converts a byte array to a UTF-8 string
* @param input The byte array to convert
* @returns {string} The converted string
*/
export function bytesToString(input: number[]): string;
/**
* Converts a UTF-8 string to a byte array
* @param input The string to convert
* @returns {number[]} The byte array representing this string
*/
export function stringToBytes(input: string): number[];
/**
* Encodes a string or byte array to base64 (RFC 3548)
* @param input The string or byte array to encode
* @returns {string} The base64 encoded string
*/
export function base64Encode(input: string | number[]): string;
/**
* Decodes a base64 (RFC 3548) string to a byte array
* @param input The base64 string to decode
* @returns {number[]} The decoded byte array
*/
export function base64Decode(input: string): number[];
/**
* Encodes a string or byte array to base85 (RFC1924)
* @param input The string or byte array to encode
* @returns {string} The base85 encoded string
*/
export function base85Encode(input: string | number[]): string;
/**
* Decodes a base85 (RFC1924) string to a byte array
* @param input The base85 string to decode
* @returns {number[]} The decoded byte array
*/
export function base85Decode(input: string): number[];
}

export = Encoding;
}
5 changes: 5 additions & 0 deletions assets/logicFunction/logic_function_name.js.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Particle from 'particle:core';

export default function process({ functionInfo, trigger, event }) {
// Add your code here
}
12 changes: 12 additions & 0 deletions assets/logicFunction/logic_function_name.logic.json.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "schemas/logic_function.schema.json",
"logic_function": {
"name": "${name}",
"description": "${description}",
"source": {
"type": "JavaScript"
},
"enabled": true,
"logic_triggers": []
}
}
5 changes: 4 additions & 1 deletion src/cli/logic-function.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ module.exports = ({ commandProcessor, root }) => {
options: {
'org': {
description: 'Specify the organization'
}
},
'name': {
description: 'Name of the logic function'
},
},
handler: (args) => {
const LogicFunctionsCmd = require('../cmd/logic-function');
Expand Down
140 changes: 133 additions & 7 deletions src/cmd/logic-function.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
const os = require('os');
const path = require('path');
const ParticleAPI = require('./api');
const CLICommandBase = require('./base');
const VError = require('verror');
const settings = require('../../settings');
const { normalizedApiError } = require('../lib/api-client');
const CLICommandBase = require('./base');
const os = require('os');
const templateProcessor = require('../lib/template-processor');
const fs = require('fs-extra');

const logicFunctionTemplatePath = path.join(__dirname, '/../../assets/logicFunction');

/**
* Commands for managing encryption keys.
Expand All @@ -19,7 +24,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase {
try {
const logicFunctions = await api.getLogicFunctionList({ org });
const orgName = getOrgName(org);
const list = logicFunctions['logic_functions'];
const list = logicFunctions.logic_functions;
if (list.length === 0) {
this.ui.stdout.write(`No Logic Functions currently deployed in your ${orgName}.`);
this.ui.stdout.write(`To create a Logic Function, see \`particle logic-function create\`.${os.EOL}
Expand All @@ -35,6 +40,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase {
});
this.ui.stdout.write(`${os.EOL}To view a Logic Function's code, see \`particle lf get.\`${os.EOL}`);
}
return list;
} catch (e) {
throw createAPIErrorResult({ error: e, message: 'Error listing logic functions' });
}
Expand All @@ -46,9 +52,129 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase {
console.log(org, name, id);
}

async create({ org, params: { filepath } }) {
// TODO
console.log(org, filepath);
async create({ org, name, params : { filepath } } = { params: { } }) {
const orgName = getOrgName(org);
const api = createAPI();
// get name from filepath
if (!filepath) {
// use default directory
filepath = '.';
}
if (!name) {
const question = {
type: 'input',
name: 'name',
message: 'What would you like to call your Function?'
};
const result = await this.ui.prompt([question]);
name = result.name;
}
// trim name
name = name.trim();
// ask for description
const question = {
type: 'input',
name: 'description',
message: 'Add a description for your Function (optional)'
};
const result = await this.ui.prompt([question]);
const description = result.description;
const slugName = name.toLowerCase().replace(/\s/g, '-');
Copy link
Member

Choose a reason for hiding this comment

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

This is the slugify function from api-service. Can you use that? Copy it to a helper

	/**
	 * Converts a string to the slug version by replacing
	 * spaces and underscores with dashes, changing
	 * to lowercase, and removing anything other than
	 * numbers, letters, and dashes
	 * @param  {String} str string to slugify
	 * @return {String}     slugified version of str
	 */
	slugify(str) {
		const slug = str.trim().toLowerCase()
			// replace every group of spaces and underscores with a single hyphen
			.replace(/[ _]+/g, '-')
			// delete everything other than letters, numbers and hyphens
			.replace(/[^a-z0-9-]/g, '');
		return slug;
	},

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done here! 👉 348a583

const destinationPath = path.join(filepath, slugName);

this.ui.stdout.write(`Creating Logic Function ${this.ui.chalk.bold(name)} for ${orgName}...${os.EOL}`);
await this._validateExistingName({ api, org, name });
await this._validateExistingFiles({ templatePath: logicFunctionTemplatePath, destinationPath });
const createdFiles = await this._copyAndReplaceLogicFunction({
logicFunctionName: name,
logicFunctionSlugName: slugName,
description,
templatePath: logicFunctionTemplatePath,
destinationPath: path.join(filepath, slugName)
});
this.ui.stdout.write(`Successfully created ${this.ui.chalk.bold(name)} in ${this.ui.chalk.bold(filepath)}${os.EOL}`);
this.ui.stdout.write(`Files created:${os.EOL}`);
createdFiles.forEach((file) => {
this.ui.stdout.write(`- ${file}${os.EOL}`);
});
this.ui.stdout.write(`${os.EOL}Guidelines for creating your Logic Function can be found <TBD>.${os.EOL}`);
this.ui.stdout.write(`Once you have written your Logic Function, run${os.EOL}`);
this.ui.stdout.write(`- \`particle logic execute\` to run your Function${os.EOL}`);
this.ui.stdout.write(`- \`particle logic deploy\` to deploy your new changes${os.EOL}`);
return createdFiles;
}

async _validateExistingName({ api, org, name }) {
// TODO (hmontero): request for a getLogicFunctionByName() method in the API
let existingLogicFunction;
try {
const logicFunctionsResponse = await api.getLogicFunctionList({ org });
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO, This can directly use the list method from this class.

Until we may have an API to get the name and id separately, I added this internal function to fetch the full list of the logic functions, and obtain the name if id is given and vice versa. @hugomontero raised a concern about downloading a potentially large list of logic functions and how that could impact the behavior or performance.

I made the above changes in the future branch: https://github.com/particle-iot/particle-cli/tree/feature/logic-function-get

const existingLogicFunctions = logicFunctionsResponse.logic_functions;
existingLogicFunction = existingLogicFunctions.find((item) => item.name === name);
} catch (error) {
this.ui.stdout.write(this.ui.chalk.yellow(`Warn: We were unable to check if a Logic Function with name ${name} already exists.${os.EOL}`));
}
if (existingLogicFunction) {
throw new Error(`Error: Logic Function with name ${name} already exists.`);
}
}

async _validateExistingFiles({ templatePath, destinationPath }){
const filesAlreadyExist = await templateProcessor.hasTemplateFiles({
templatePath,
destinationPath
});
if (filesAlreadyExist) {
const question = {
type: 'confirm',
name: 'overwrite',
message: `We found existing files in ${this.ui.chalk.bold(destinationPath)}. Would you like to overwrite them?`
};
const { overwrite } = await this.ui.prompt([question]);
if (!overwrite) {
this.ui.stdout.write(`Aborted.${os.EOL}`);
process.exit(0);
}
}
}

/** Recursively copy and replace template files */
async _copyAndReplaceLogicFunction({ logicFunctionName, logicFunctionSlugName, description, templatePath, destinationPath }){
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This can be refactored. I'll do that in further branches

const files = await fs.readdir(templatePath);
const createdFiles = [];

for (const file of files){
//createdFiles.push(destinationFile);
// check if file is a dir
const stat = await fs.stat(path.join(templatePath, file));
if (stat.isDirectory()) {
const subFiles = await this._copyAndReplaceLogicFunction({
logicFunctionName,
logicFunctionSlugName,
description,
templatePath: path.join(templatePath, file),
destinationPath: path.join(destinationPath, file)
});
createdFiles.push(...subFiles);
} else {
const fileReplacements = {
'logic_function_name': logicFunctionSlugName,
};
const destinationFile = await templateProcessor.copyAndReplaceTemplate({
fileNameReplacements: fileReplacements,
file,
templatePath,
destinationPath,
replacements: {
name: logicFunctionName,
description: description || ''
}
});
createdFiles.push(destinationFile);
}
}
// return file name created
return createdFiles;
}

async execute({ org, data, params: { filepath } }) {
Expand Down Expand Up @@ -92,7 +218,7 @@ function createAPIErrorResult({ error: e, message, json }){

// get org name from org slug
function getOrgName(org) {
return org || 'Staging';
return org || 'Sandbox';
}

// TODO (mirande): reconcile this w/ `normalizedApiError()` and `ensureError()`
Expand Down