diff --git a/articles/2021-07-05-wrapping-my-head-around-jobs.md b/articles/2021-07-05-wrapping-my-head-around-jobs.md index 8c22b7272bb..65ced6180e7 100644 --- a/articles/2021-07-05-wrapping-my-head-around-jobs.md +++ b/articles/2021-07-05-wrapping-my-head-around-jobs.md @@ -86,7 +86,7 @@ And for a fail triggered job like this: ``` No matter what, jobs start with state. See -["Initial and final state for runs"](/documentation/build/steps/state/) for a detailed +["Initial and final state for runs"](/documentation/jobs/state) for a detailed breakdown. ## It ends with `state` too diff --git a/docs/build-for-developers/jobs.md b/docs/build-for-developers/jobs.md deleted file mode 100644 index eb9ea945b36..00000000000 --- a/docs/build-for-developers/jobs.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Jobs ---- - -Jobs define the specific series of "operations" (think: tasks or database actions) to be performed in an individual Workflow Step. See the [Steps section](../build/steps/steps.md) for detailed documentation and tips for [writing Job expressions](../build/steps/jobs.md). - -:::note - -In OpenFn V1, there was no concept of `Workflow Steps`--they were referred to as `Jobs`. In V2, we now refer to Jobs as the "job expressions" or "script" that define the business logic and transformation rules of individual `Steps`. - -::: - diff --git a/docs/build/steps/editing-locally.md b/docs/build/editing-locally.md similarity index 100% rename from docs/build/steps/editing-locally.md rename to docs/build/editing-locally.md diff --git a/docs/build/steps/each.md b/docs/build/steps/each.md deleted file mode 100644 index d0c25fe0944..00000000000 --- a/docs/build/steps/each.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -id: each -title: The each(...) operation ---- - -The `each` operation allows you to perform another operation on each item in an -array. - -## Each takes two arguments - -In other words, `each(arrayPath, operation)` will _do_ `operation` on each item -it finds in the `arrayPath` array. It takes just two arguments: - -1. an arrayPath -2. an operation(...) - -### arrayPath - -Let's look at the first argument in `each`... the path to the array. Consider -the following code using the Salesforce adaptor: - -```js -each( - dataPath('form.participants[*]'), - upsert( - 'Person__c', - 'Participant_Identification_Number_PID__c', - fields( - field('Participant_Identification_Number_PID__c', dataValue('pid')), - relationship('RecordType', 'Name', 'Participant'), - field('First_Name__c', dataValue('participant_first_name')), - field('Surname__c', dataValue('participant_surname')), - field('Mobile_Number_1__c', dataValue('mobile_number')) - field('Sex__c', dataValue('gender')), - ) - ) -); -``` - -This will upsert a `Person__c` resource in Salesforce for each item found in the -`state.data.form.participants` array. You could specify this path in the -following ways: - -- `'$.data.form.participants[*]'` -- `dataPath('form.participants[*]')` - -Note the JSON path syntax. - -### the operation - -If there are 5 participants in there, it will execute the `upsert` operation on -all 5 items, in sequence. `upsert` takes whatever arguments it takes normally -but it operates _inside_ the array. See below for more details on the _scope_ of -this operation. - -## dataValue(...) _inside_ each(...) - -Note that inside the `each(...)` operation, using `dataValue(path)` will -evaluate a path inside each item in the array. - -## merge(...) and bringing data 'down' into an array: - -What if you want to access data in your `upsert` operation that does _not_ exist -in the array itself. You could use a data preparation step (see: `alterState`) -or make use of `merge(path, data)` which allows you to merge data from the -initial scope down into your array and access it from the `upsert` operation. - -```js -each( - merge( - dataPath('form.participants[*]'), - fields( - field('school_id', dataValue('form.school.id')), - field('intervention_type', dataValue('form.type')) - ) - ), - upsert( - 'Person__c', - 'Participant_Identification_Number_PID__c', - fields( - field('Participant_Identification_Number_PID__c', dataValue('pid')), - relationship('RecordType', 'Name', 'Participant'), - field('First_Name__c', dataValue('participant_first_name')), - field('Surname__c', dataValue('participant_surname')), - field('Mobile_Number_1__c', dataValue('mobile_number')) - field('Sex__c', dataValue('gender')), - // new fields... - field('School__c', dataValue('school_id')), - field('Intervention_Type__c', dataValue('intervention_type')) - ) - ) -); -``` diff --git a/docs/build/steps/jobs.md b/docs/build/steps/jobs.md deleted file mode 100644 index cf510cbf7e9..00000000000 --- a/docs/build/steps/jobs.md +++ /dev/null @@ -1,601 +0,0 @@ ---- -title: Write Jobs -sidebar_label: Write Jobs ---- - -To define the business logic and data transformation rules or logic for -individual `Steps` in your workflow, you will need to write a `Job`. This -article will provide a basic overview of Job expressions & writing tips. - -:::tip - -For example Jobs written by the OpenFn core team and other users, check out the -[Library](/adaptors/library) or other project repositories under -[Github.com/OpenFn](https://github.com/OpenFn). - -::: - -## About Jobs - -A `Job` is evaluated as a JavaScript expression and primarily defines the -specific series of [Operations](/docs/build/steps/operations.md) (think: tasks, -database actions, custom functions) to be performed in a specific Workflow Step. - -In most cases, a Job is a series of `create` or `upsert` operations that are -triggered by a webhook event (e.g., form submission forwarded from ODK mobile -data collection app) or cron (e.g., daily @ 12:00). See this basic example: - -```js -create( - 'Patient__c', - fields( - field('Name', dataValue('form.surname')), - field('Other Names', dataValue('form.firstName')), - field('Age__c', dataValue('form.ageInYears')), - field('Is_Enrolled__c', true), - field('Enrollment_Status__c', 3) - ) -); -``` - -This would create a new `Patient__c` record in the connected app. The patient's -`Name` will be mapped from the form submission forwarded to OpenFn from the -source mobile data collection app via a webhook request. Then, because we assume -that all Patients registered in the mobile app are always considered "enrolled", -we hard code the patient enrollment status as true - see the mapping for -`Is_Enrolled__c`. - -The functions see above is OpenFn's own syntax, and you've got access to dozens -of common "helper functions" like `dataValue(path)` and destination specific -functions like `create(object, attributes)`. You can choose to either write jobs -using this OpenFn syntax or write your own custom, anonymous functions in -JavaScript to do whatever your heart desires. Remember that **Steps are -evaluated as JavaScript**. - -### dataValue - -The most commonly used "helper function" is `dataValue(...)`. This function -takes a single argument—the _path_ to some data that you're trying to access -inside the message that has triggered a particular run. In the above example, -you'll notice that `Is_Enrolled__c` is _always_ set to `true`, but `Name` will -change for each message that triggers the running of this step. It's set to -`dataValue('form.surname')` which means it will set `Name` to whatever value is -present at `state.data.form.surname` for the triggering webhook request. It -might be Bob for one message, and Alice for another. - -:::note - -Note that for message-triggered steps, `state` will always have it's `data` key -(i.e., `state.data`) set to the body of the triggering webhook request (aka HTTP -request). - -I.e., `dataValue('some.path') === state.data.some.path`, as evaluated at the -time that the operation (`create` in the above expression) is executed. - -::: - -### A Job with custom JavaScript - -To write your own custom JavaScript functions, simply add an `fn(...)` block to -your code as below. - -```js -fn(state => { - //write your own function to manipulate/transform state - return state; -}); -``` - -Alternatively, you can add custom JavaScript code in-line any Adaptor-specific -functions. See example job below where JavaScript was added to transform the -data value outputted for `Name`. - -```js -create( - 'Patient__c', - fields( - field('Name', state => { - console.log('Manipulate state to get your desired output.'); - return Array.apply(null, state.data.form.names).join(', '); - }), - field('Age__c', 7) - ) -); -``` - -Here, the patient's name will be a comma separated concatenation of all the -values in the `patient_names` array from our source message. - -## Available JavaScript Globals - -For security reasons, users start with access to the following standard -JavaScript globals, and can request more by opening an issue on Github: - -- [`Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) -- [`console`](https://nodejs.org/api/console.html) -- [`JSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) -- [`Number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number) -- [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) -- [`String`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) - -## Examples of adaptor-specific functions - -**N.B.: This is just a sample.** There are lots more available in the -[Adaptors docs](/adaptors/) and -[repository](https://github.com/OpenFn/adaptors). - -### language-common - -These are available functions "common" across Adaptors. You can use any of these -when writing Job expressions. - -- `field('destination_field_name__c', 'value')` Returns a key, value pair in an - array. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L364) -- `fields(list_of_fields)` zips key value pairs into an object. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L377) -- `dataValue('JSON_path')` Picks out a single value from source data. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L146) -- `each(JSON_path, operation(...))` Scopes an array of data based on a JSONPath - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L262). - See beta.each when using multiple each()'s in an expression. -- `each(merge(dataPath("CHILD_ARRAY[*]"),fields(field("metaId", dataValue("*meta-instance-id*")),field("parentId", lastReferenceValue("id")))), create(...))` - merges data into an array then creates for each item in the array - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L396) -- `lastReferenceValue('id')` gets the sfID of the last item created - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/common/src/Adaptor.js#L175) -- `fn(state){return state.references[state.references.length-N].id})` gets the - sfID of the nth item created - -#### each() - -For more on the `each(...)` operation, see -[this page](/documentation/build/steps/each) and the below example. - -```js -each( - dataPath('csvData[*]'), - upsertTEI( - 'aX5hD4qUpRW', //piirs uid - { - trackedEntityType: 'bsDL4dvl2ni', - orgUnit: dataValue('OrgUnit'), - attributes: [ - { - attribute: 'aX5hD4qUpRW', - value: dataValue('aX5hD4qUpRW'), - }, - { - attribute: 'MxQPuS9G7hh', - value: dataValue('MxQPuS9G7hh'), - }, - ], - }, - { strict: false } - ) -); -``` - -### Salesforce - -See below for some example functions for the Salesforce Adaptor. See the -[Adaptor](/adaptors/) for more. - -- `create("DEST_OBJECT_NAME__C", fields(...))` Create a new object. Takes 2 - parameters: An object and attributes. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/salesforce/src/Adaptor.js#L466-L480) -- `upsert("DEST_OBJECT_NAME__C", "DEST_OBJECT_EXTERNAL_ID__C", fields(...))` - Creates or updates an object. Takes 3 paraneters: An object, an ID field and - attributes. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/salesforce/src/Adaptor.js#L539-L560) -- `relationship("DEST_RELATIONSHIP_NAME__r", "EXTERNAL_ID_ON_RELATED_OBJECT__C", "SOURCE_DATA_OR_VALUE")` - Adds a lookup or 'dome insert' to a record. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/salesforce/src/Adaptor.js#L49-L56) - -### dhis2 - -See below for some example functions for the DHIS2 Adaptor. See the -[Adaptor](/adaptors/) for more. - -- `create('events', {..})` Creates an event. - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/dhis2/src/Adaptor.js#L137-L142) -- `create('dataValueSet', {..})` Send data values using the dataValueSets - resource - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/dhis2/src/Adaptor.js#L185-L191) - -### OpenMRS - -See below for some example functions for the OpenMRS Adaptor. See the -[Adaptor](/adaptors/) for more. - -- `create('person',{..})` Takes a payload of data to create a person - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/openmrs/src/Adaptor.js#L292-L311) -- `createPatient(...)` Takes a payload of data to create a patient - [(source)](https://github.com/OpenFn/adaptors/blob/main/packages/openmrs/src/Adaptor.js#L292-L311) - -## Snippets and samples - -Below you can find some examples of block code for different functions and data -handling contexts. - -
-Step expression (for commcare to SF) - -The following step expression will take a matching receipt and use data from -that receipt to upsert a `Patient__c` record in Salesforce and create multiple -new `Patient_Visit__c` (child to Patient) records. - -```js -upsert( - 'Patient__c', - 'Patient_Id__c', - fields( - field('Patient_Id__c', dataValue('form.patient_ID')), - relationship('Nurse__r', 'Nurse_ID_code__c', dataValue('form.staff_id')), - field('Phone_Number__c', dataValue('form.mobile_phone')) - ) -), - each( - join('$.data.form.visits[*]', '$.references[0].id', 'Id'), - create( - 'Visit__c', - fields( - field('Patient__c', dataValue('Id')), - field('Date__c', dataValue('date')), - field('Reason__c', dataValue('why_did_they_see_doctor')) - ) - ) - ); -``` - -
- -
-Accessing the "data array" in Open Data Kit submissions - -Notice how we use "each" to get data from each item inside the "data array" in -ODK. - -```js -each( - '$.data.data[*]', - create( - 'ODK_Submission__c', - fields( - field('Site_School_ID_Number__c', dataValue('school')), - field('Date_Completed__c', dataValue('date')), - field('comments__c', dataValue('comments')), - field('ODK_Key__c', dataValue('*meta-instance-id*')) - ) - ) -); -``` - -
- -
-ODK to Salesforce: create parent record with many children from parent data - -Here, the user brings `time_end` and `parentId` onto the line items from the -parent object. - -```js -each( - dataPath('data[*]'), - combine( - create( - 'transaction__c', - fields( - field('Transaction_Date__c', dataValue('today')), - relationship( - 'Person_Responsible__r', - 'Staff_ID_Code__c', - dataValue('person_code') - ), - field('metainstanceid__c', dataValue('*meta-instance-id*')) - ) - ), - each( - merge( - dataPath('line_items[*]'), - fields( - field('end', dataValue('time_end')), - field('parentId', lastReferenceValue('id')) - ) - ), - create( - 'line_item__c', - fields( - field('transaction__c', dataValue('parentId')), - field('Barcode__c', dataValue('product_barcode')), - field('ODK_Form_Completed__c', dataValue('end')) - ) - ) - ) - ) -); -``` - -
- -
-Salesforce: perform an update - -```js -update("Patient__c", fields( - field("Id", dataValue("pathToSalesforceId")), - field("Name__c", dataValue("patient.first_name")), - field(...) -)); -``` - -
- -
-Salesforce: Set record type using 'relationship(...)' - -```js -create( - 'custom_obj__c', - fields( - relationship( - 'RecordType', - 'name', - dataValue('submission_type'), - field('name', dataValue('Name')) - ) - ) -); -``` - -
- -
-Salesforce: Set record type using record Type ID - -```js -each( - '$.data.data[*]', - create( - 'fancy_object__c', - fields( - field('RecordTypeId', '012110000008s19'), - field('site_size', dataValue('size')) - ) - ) -); -``` - -
- -
-Telerivet: Send SMS based on Salesforce workflow alert - -```js -send( - fields( - field( - 'to_number', - dataValue( - 'Envelope.Body.notifications.Notification.sObject.phone_number__c' - ) - ), - field('message_type', 'sms'), - field('route_id', ''), - field('content', function (state) { - return 'Hey there. Your name is '.concat( - dataValue('Envelope.Body.notifications.Notification.sObject.name__c')( - state - ), - '.' - ); - }) - ) -); -``` - -
- -
-Sample DHIS2 events API step - -```js -event( - fields( - field('program', 'eBAyeGv0exc'), - field('orgUnit', 'DiszpKrYNg8'), - field('eventDate', dataValue('properties.date')), - field('status', 'COMPLETED'), - field('storedBy', 'admin'), - field('coordinate', { - latitude: '59.8', - longitude: '10.9', - }), - field('dataValues', function (state) { - return [ - { - dataElement: 'qrur9Dvnyt5', - value: dataValue('properties.prop_a')(state), - }, - { - dataElement: 'oZg33kd9taw', - value: dataValue('properties.prop_b')(state), - }, - { - dataElement: 'msodh3rEMJa', - value: dataValue('properties.prop_c')(state), - }, - ]; - }) - ) -); -``` - -
- -
-merge many values into a child path - -```js -each( - merge( - dataPath("CHILD_ARRAY[*]"), - fields( - field("metaId", dataValue("*meta-instance-id*")), - field("parentId", lastReferenceValue("id")) - ) - ), - create(...) -) -``` - -
- -
-arrayToString - -```js -arrayToString(arr, separator_string); -``` - -
- -
-access an image URL from an ODK submission - -```js -// In ODK the image URL is inside an image object... -field("Photo_URL_text__c", dataValue("image.url")), -``` - -
- -
-Use external ID fields for relationships during a bulk load in Salesforce - -```js -fn(state) => { - const patients = array.map(item => { - return { - Patient_Name__c: item.fullName, - 'Account.Account_External_ID__c': item.account - 'Clinic__r.Unique_Clinic_Identifier__c': item.clinicId, - 'RecordType.Name': item.type, - }; - }); - - return {...state, patients} -} -``` - -
- -
-Bulk upsert with an external ID in salesforce - -```js -bulk( - 'Visit_new__c', - 'upsert', - { - extIdField: 'commcare_case_id__c', - failOnError: true, - allowNoOp: true, - }, - dataValue('patients') -); -``` - -
- -## Anonymous Functions - -Different to [Named Functions](#examples-of-adaptor-specific-functions), -Anonymous functions are generic pieces of JavaScript which you can write to suit -your needs. Read on for some examples of these custom functions. - -### Custom replacer - -```js -field('destination__c', state => { - console.log(something); - return dataValue('path_to_data')(state).toString().replace('cats', 'dogs'); -}); -``` - -This will replace all "cats" with "dogs" in the string that lives at -`path_to_data`. - -> **NOTE:** The JavaScript `replace()` function only replaces the first instance -> of whatever argument you specify. If you're looking for a way to replace all -> instances, we suggest you use a regex like we did in the -> [example](#custom-concatenation-of-null-values) below. - -### Custom arrayToString - -```js -field("target_specie_list__c", function(state) { - return Array.apply( - null, sourceValue("$.data.target_specie_list")(state) - ).join(', ') -}), -``` - -It will take an array, and concatenate each item into a string with a ", " -separator. - -### Custom concatenation - -```js -field('ODK_Key__c', function (state) { - return dataValue('metaId')(state).concat('(', dataValue('index')(state), ')'); -}); -``` - -This will concatenate two values. - -### Concatenation of null values - -This will concatenate many values, even if one or more are null, writing them to -a field called Main_Office_City_c. - -```js -... - field("Main_Office_City__c", function(state) { - return arrayToString([ - dataValue("Main_Office_City_a")(state) === null ? "" : dataValue("Main_Office_City_a")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_b")(state) === null ? "" : dataValue("Main_Office_City_b")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_c")(state) === null ? "" : dataValue("Main_Office_City_c")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_d")(state) === null ? "" : dataValue("Main_Office_City_d")(state).toString().replace(/-/g, " "), - ].filter(Boolean), ',') - }) -``` - -> Notice how this custom function makes use of the **regex** `/-/g` to ensure -> that all instances are accounted for (g = global search). - -### Custom Nth reference ID - -If you ever want to retrieve the FIRST object you created, or the SECOND, or the -Nth, for that matter, a function like this will do the trick. - -```js -field('parent__c', function (state) { - return state.references[state.references.length - 1].id; -}); -``` - -See how instead of taking the id of the "last" thing that was created in -Salesforce, you're taking the id of the 1st thing, or 2nd thing if you replace -"length-1" with "length-2". - -### Convert date string to standard ISO date for Salesforce - -```js -field('Payment_Date__c', function (state) { - return new Date(dataValue('payment_date')(state)).toISOString(); -}); -``` - -> **NOTE**: The output of this function will always be formatted according to -> GMT time-zone. diff --git a/docs/build/steps/multiple-operations.md b/docs/build/steps/multiple-operations.md deleted file mode 100644 index cad33a9576c..00000000000 --- a/docs/build/steps/multiple-operations.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Chaining operations in 1 Step -sidebar_label: Chaining operations ---- - -This page describes why you might want to chain multiple operations in a single Job when designing your Step. - -![Chaining Steps](/img/chaining-operations.png) - -### Reasons to use multiple operations in a single Job - -- Your desired final `Output` for a Step requires multiple operations to produce - (e.g., get Patients to check if existing, then update existing Patient - records). -- The job must be atomic, you want the whole thing to count as a failure if any - part of it fails. -- You run Steps manually and you want a single button to click to retry the - entire sequence of operations. -- You update a `cursor` in a series of operations that involve `GET` and `POST`. - When the `POST` fails, you don't want to update the `cursor` for the - subsequent job run which contains the `GET`. - - See example Job below with 2 "upsert" (update/insert) operations. - - ```js - //first we upsert the Patient - upsert( - 'Patient__c', - 'ExternalId', - fields( - field('ExternalId', dataValue('form.patient_id')), - field('Name', dataValue('form.surname')), - field('Other Names', dataValue('form.firstName')), - field('Age__c', dataValue('form.ageInYears')), - field('Is_Enrolled__c', true), - field('Enrollment_Status__c', 3) - ) - ); - //then we upsert the related Visit (child to the Patient record) - create( - 'Visit__c', - 'ExternalId', - fields( - field('ExternalId', dataValue('form.visit_id')), - relationship('Patient__r', dataValue('form.patient_id')), - field('Name', dataValue('form.surname')), - field('Other Names', dataValue('form.firstName')), - field('Age__c', dataValue('form.ageInYears')), - field('Is_Enrolled__c', true), - field('Enrollment_Status__c', 3) - ) - ); - ``` -Check out the Job [Library Examples](/adaptors/library/) for more examples. diff --git a/docs/build/steps/operations.md b/docs/build/steps/operations.md deleted file mode 100644 index 8ffad28b07e..00000000000 --- a/docs/build/steps/operations.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Operations ---- - -In a Job expression, an Operation is a function which returns a function which takes `state` and -returns a `Promise` or `state`. - -The purpose of an Operation is to act as an unresolved unit of behaviour. - -For example, when drafting a Job expression - the code itself doesn't know what the -state is going to be, only what _it's going to do_. - -Adaptors all follow this convention, where the functions that are provided -all return Operations. - -```javascript -create('My_Custom_Object__c', { - Custom_Field__c: dataValue('foo'), -}); -``` - -In the snippet above, the `create` function doesn't know anything about -credentials, or any dynamic data that you may be available at runtime. - -```javascript -function create(objectName, data) { - return function (state) { - // expand the data argument using state - // actually do the work - }; -} -``` - -In this snippet is a simple example of what most functions in OpenFn look like. -The `create` function returns a function that takes state, this is an -`Operation`. The runtime using `execute` will call all Operations with `state`. diff --git a/docs/build/steps/state.md b/docs/build/steps/state.md deleted file mode 100644 index 1796cf5d359..00000000000 --- a/docs/build/steps/state.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Initial and final state ---- - -Each Step requires an input state and (in most cases) will produce an output -state. This article explains these concepts in greater detail. - -![Job State Overview](/img/state-javascript.png) - -## Input & output state for runs - -Depending on whether you're running Workflows locally or on the app, the input -`state` for a Run can be generated differently. When creating a work order by -hand, you must select or generate your input manually (e.g., by creating a -custom `Input` on the app or `state.json` file if working locally -[in the CLI](/docs/build-for-developers/cli-intro.md)). When a work order is -automatically created via a webhook trigger or cron trigger, state will be -created as described below. - -The final state of a Run is determined by what's returned from the last -operation. Remember that job expressions are a series of `operations`—they each -take `state` and return `state`, after creating any number of side effects. You -can control what is returned at the end of all of these operations. - -### Webhook triggered runs - -Initial state contains important parts of the inbound **http request**. - -```js -{ - data: httpRequest.body, - request: { headers: httpRequest.headers }, - configuration: credential.body -} -``` - -### Cron triggered runs - -Initiate state is either an empty object `{}` or the final state of the **last -succesful run** for this workflow. - -```js -{ - ...finalStateOfLastSuccessfulRun, - configuration: credential.body -} -``` - -## Input & output state for steps - -State is also passed between each step in a workflow. The output state of the -previous step is used as the input state for the next step. - -### On success - -When a job succeeds, its output state will be whatever is returned by the last -operation. - -```js -{ - data: "blah", - references: [1, 2, 3] -} -``` - -### On failure - -When a step in a workflow fails, the error will be added to an `errors` object -on state, keyed by the ID of the job that failed. - -```js -{ - data: "blah", - references: [1, 2, 3], - errors: { jobId: error } -} -``` - -See the below diagram for a visual description of how state might be passed -between Steps in a Workflow. - -![Passing State](/img/passing-state-steps.png) diff --git a/docs/build/steps/step-design-intro.md b/docs/build/steps/step-design-intro.md index 43e9494cf35..7550098f0ff 100644 --- a/docs/build/steps/step-design-intro.md +++ b/docs/build/steps/step-design-intro.md @@ -8,8 +8,8 @@ Read on for a brief overview. :::tip -Check out the [Workflow Design](/documentation/design/design-overview) docs -for more details on solution design and links to templates. +Check out the [Workflow Design](/documentation/design/design-overview) docs for +more details on solution design and links to templates. ::: @@ -29,9 +29,9 @@ and consider summarizing your design specifications in a ### 2: Map your data elements -[See here](/documentation/design/mapping-specs) for detailed guidance on -mapping data elements or "data dictionaries" between your source and destination -apps. To get started: +[See here](/documentation/design/mapping-specs) for detailed guidance on mapping +data elements or "data dictionaries" between your source and destination apps. +To get started: 1. Export the metadata (or "form", "field list", or "data elements") of your source app (input) & destination app (output). @@ -63,7 +63,7 @@ apps. To get started: - `updateTEI(...)` - `upsertTEI(...)` -See example [Job expression](/docs/build/steps/jobs.md) for a Step +See example [Job expression](/documentation/jobs/job-writing-guide) for a Step that will "upsert" (update or insert) records in a SQL database. ```js diff --git a/docs/build/steps/step-editor.md b/docs/build/steps/step-editor.md index 02634ba145c..ffb882249a1 100644 --- a/docs/build/steps/step-editor.md +++ b/docs/build/steps/step-editor.md @@ -20,8 +20,8 @@ To access this interface: For more on how to write custom business logic and data transformation rules in the `Editor`, see the docs on -[writing Jobs](/documentation/build/steps/jobs) and check out -the below video. +[writing Jobs](/documentation/jobs/job-writing-guide) and check out the below +video. @@ -40,7 +40,6 @@ include `Logs` and an `Output`. - `Logs` - A record generated by the workflow execution engine that details the activities performed when running a Workflow or individual Step. -See [Writing Jobs docs](/documentation/build/steps/jobs) for -more on writing custom logic, and see -[this article](/documentation/build/steps/state) for more on the concept of -"state" when writing Jobs and building OpenFn Workflows. +See [Writing Jobs docs](/documentation/jobs/job-writing-guide) for more on +writing custom logic, and see [this article](/documentation/jobs/state) for more +on the concept of "state" when writing Jobs and building OpenFn Workflows. diff --git a/docs/build/steps/steps.md b/docs/build/steps/steps.md index 506de66354d..ac31dc48470 100644 --- a/docs/build/steps/steps.md +++ b/docs/build/steps/steps.md @@ -6,6 +6,14 @@ A Step is a specific task or activity in a workflow. Each Step is linked to an [Adaptor](/adaptors/) and contains business logic to perform a specific task or operation in that target app. Read on to learn more. +:::note + +In OpenFn V1, there was no concept of `Workflow Steps`--they were referred to as +`Jobs`. In V2, we now refer to Jobs as the "job expressions" or "script" that +define the business logic and transformation rules of individual `Steps`. + +::: + ## Create or edit a Step Via the Workflow Canvas, click the plus `+` icon to create a _new_ Step, or @@ -134,4 +142,4 @@ want this and to avoid the risk of accidental upgrades on live Workflows. Click the code button `` displayed on the configuration panel to write or edit a Job expression to define the "rules" or the specific tasks to be completed by your Step. See the pages on [the Inspector](./step-editor.md) and -[writing Jobs](./jobs.md) to learn more. +[writing Jobs](/documentation/jobs/job-writing-guide) to learn more. diff --git a/docs/build/steps/working-with-branches.md b/docs/build/working-with-branches.md similarity index 80% rename from docs/build/steps/working-with-branches.md rename to docs/build/working-with-branches.md index 636d0c20e88..d58d6299a58 100644 --- a/docs/build/steps/working-with-branches.md +++ b/docs/build/working-with-branches.md @@ -3,13 +3,14 @@ title: Manage changes with Github branches sidebar_label: Manage changes --- -In the [Edit Steps Locally](/documentation/build/steps/editing-locally) -section, we walked through the process of creating and adding your changes to -the `main` branch of a project. +In the [Edit Steps Locally](../build/editing-locally.md) section, we walked through +the process of creating and adding your changes to the `main` branch of a +project. -However, most code change to workflows involve sharing and reviewing changes before -deployment. You can do this by creating, testing and sharing your changes on a -new Github branch, then, once final, merging them into `main` for deployment. +However, most code change to workflows involve sharing and reviewing changes +before deployment. You can do this by creating, testing and sharing your changes +on a new Github branch, then, once final, merging them into `main` for +deployment. :::tip @@ -28,8 +29,8 @@ repo to your local folder. branch. When you start editing your steps, the changes will be kept on this branch, managed separately from `main`. -2. To test the changes locally, check out the [The CLI](/documentation/cli) - docs. +2. To test the changes locally, check out the + [The CLI](../build-for-developers/cli-intro.md) docs. 3. Just as you've seen when working on `main`, when you're done check which files you changed with `git status`. diff --git a/docs/jobs/javascript.md b/docs/jobs/javascript.md new file mode 100644 index 00000000000..e06140f08db --- /dev/null +++ b/docs/jobs/javascript.md @@ -0,0 +1,267 @@ +--- +title: Javascript Tips +sidebar_label: Javascript Tips +--- + +OpenFn supports all modern JavaScript features. + +This section highlights some useful JavaScript features and operators which +might help make your code cleaner. This is not meant to be an exhaustive guide, +just a pointer to some good techniques on some of the newer aspects of the +language. + +### Variables: var vs let vs const + +JavaScript gives you three different ways to declare variables, and this can be +a bit confusing. + +- `var` is a variable that can have its value reassigned. You can re-declare a + `var` multiple times. +- `let` is basically the same as a var, but cannot be redeclared and has subtly + different scoping rules. +- `const` is used for variable whose values do not change. + +It doesn't really matter which style you use (except perhaps if you try to +assign to a `const`). + +Most OpenFn jobs are written in quite a functional style anyway - you may find +you don't even need to declare variables. + +
+What is functional programming? +Functional programming is a style of programming, increasingly popular in modern Javascript. + +Broadly, the idea is to minimize the usage of control flow statements (like +`if/else`,`for`) and instead use chains of functions. In functional programming +we pass data through a pipeline to get the result we want sound familiar?). + +```js +const items = [10, 109, 55]; + +// Imperative JS +const transformedItems = []; +for (const i of items) { + if (i < 100) { + transformedItems.push(i * 2); + } +} + +// Functional js +const transformedItems = items.filter(x => x > 100).map(x => x * 2); +``` + +Functional programming tends to be more terse and condensed than regular, +imperative programming. This is a good and bad thing - but if you're used to the +style, it tends to be very readable and translates well across languages. + +
+ +Most modern, idiomatic JavaScript is written with `const` and `let`. This can +actually make your code more readable and intentional, and the rules are +actually pretty simple: + +- Use a `const` if you don't want a variable value to change. +- Use a `let` if you expect variable value to change. + +This can get a little tricky with objects and arrays. We can assign an object to +a const, but still change the properties of the object. The same for arrays. +This is all to do with pointers and how JavaScript stores variables - the key to +it is that you're not assigning a new value to the variable, but you are +modifying the _contents_ of the variable. + +Check these examples: + +```js +// Example 1: Objects +const data = {}; + +// We can mutate the object here +// The data variable is still referencing the same object +data.name = 'OpenFn'; + +data = { name: 'Lightning' }; // This throws a runtime error because we are re-assigning the variable! + +// Example 2: Arrays +const ids = [1, 2, 3]; + +// We can call functions on the ids array, which will mutate the array's contents +ids.push(4); +ids.pop(); + +// But we cannot re-assign the variable + +ids = [4, 5, 6]; // This throws a runtime error because we are re-assigning the variable! +``` + +### Optional chaining + +JavaScript is an untyped language - which is very conveient for OpenFn jobs and +usually makes life easier. + +However, a common problem is that when writing long property chains, an +exception will be thrown if a property is missing. And this happens all the time +when fetching data from remote servers. + +Optional chaning allows JavaScript to stop evaluating a property chain and +return undefined as the result of that whole expression: + +```js +const x = a.b?.c?.d?.e; +``` + +In this example, if `c`, for example, is not defined, then `x` will be given a +value of `undefined`. No exception will be thrown. + +You can do this with string properties too, although the syntax is a bit +fiddlier: + +```js +const x = a.b['missing-link']?.d?.e; +``` + +This can also be used for optional function calls (less useful in job writing +but included for completeness): + +```js +const x = a.b?.(); +``` + +You can combine optional chaning with the wonderfully named **"nullish +coalescing"** operator. This works a bit like a ternary expression or an or - if +anything to left of the operator returns `null` or `undefined`, the value to the +right will be returned. + +```js +const x = a.b?.c?.d?.e ?? 22; +``` + +In this example, if any of the values in the chain are not defined, `x` will be +assigned a value of 22. + +### Arrow functions + +Arrow functions are used throughout this guide and we expect that most users are +familiar with their usage. + +An arrow function is an alternative way to write a JavaScript function. There +are a few reasons why they are popular in modern JavaScript: + +- They feel lightweight, with less syntax required +- They do not have a `this` scope - although this is largely irrelevant to + OpenFn programming (and indeed most modern JS frameworks) + +Arrow functions are always anonymous - they have no name - but they can be +assigned to variables of course. + +```js +function upperCase(name) { + return name.toUpperCase(); +} + +const getName = () => { + return name.toUpperCase(); +}; +``` + +An arrow function can contain a single expression and no body, and will return +the expression: + +```js +function getX() { + return x; +} + +const getX = () => x; +``` + +This pattern makes arrow functions lightweight and elegant, and aligns nicely +with functional programming paradigms. + +:::tip Problems returning an object? + +Always wrap objects in brackets when returning an object from an arrow +expression: + +``` +post('wwww', () => ({ id: 'a', name: 'adam' })) +``` + +When Javascript sees a brace `{` after an arrow, it expects to see a block of +statements, not an object. Wrapping the object in brackets tells Javascript to +parse an expression instead of a block. + +::: + +### Rest and spread operators + +The spread or rest operator `...` can be used for several purposes. It can be +quite complex to understand, but in OpenFn it has a couple of strong uses. + +First, you can **"spread"** or **"apply"** the properties and value of one (or +more) objects to a new object. This is a really conveient way to shallow clone +objects. + +It works a lot like `Object.assign(obj, first, second, third)`. + +Here's how we shallow clone with spread: + +```js +const newState = { + ...state, +}; +``` + +Properties are declared in sequence, so you can spread an object and then +declare more properties: + +```js +const newState = { + ...state + data: {} // create a new data object but keep all other keys of state +} +``` + +You can spread multiple objects, which again will apply in order. This example +applies some default values, then overwrites with whatever is on state, then +finally overwrites the data key. + +```js +const newState = { + ...defaults, + ...state + data: {} // create a new data object but keep all other keys of state +} +``` + +Spreading like this does not affect the original object (ie, in the example +above, `defaults` and `state` are not changed), although remember that this is +only a shallow clone, and non-primitive values use pointers, not copies. + +
+What is a shallow clone? +To shallow clone an object means to copy all the top-level keys and values of that object onto a new object. + +But this ONLY applies to top-level keys. And if a value contains an object, +you're really just copying a _pointer_ to that object. + +```js +const a = { + x: 1, + y: { + values: [1, 2, 3] + } +}; + +// declare b as a shallow clone of a +const b = { + ... a +} + +b.x = 2; // a.x is unchanged +b.y.values = []; // a.y.values is changed +b.y = 20' // a.y is unchanged +``` + +A deep clone means that all properties in the whole object tree are cloned. + +
diff --git a/docs/build/steps/job-examples.md b/docs/jobs/job-examples.md similarity index 71% rename from docs/build/steps/job-examples.md rename to docs/jobs/job-examples.md index 8a9e9fa3635..bf292919d8b 100644 --- a/docs/build/steps/job-examples.md +++ b/docs/jobs/job-examples.md @@ -1,16 +1,24 @@ --- title: Job Code Examples -sidebar_label: Job Code Snippets & Examples +sidebar_label: Job Examples --- -## Snippets and samples +Below you can find some code blocks for different functions and data handling +contexts to use in your Jobs. -Below you can find some code block for different functions and data -handling contexts to use in your Jobs. **Also see the [Library Examples](/adaptors/library) for more Job examples for other Adaptors.** +:::tip + +For example Jobs written by the OpenFn core team and other users, check out the +[Library](/adaptors/library) or other project repositories under +[Github.com/OpenFn](https://github.com/OpenFn). + +::: :::info Questions? -If you have any job-writing questions, ask on [Community](https://community.openfn.org) to seek assistance from the OpenFn core team and other implementers. +If you have any job-writing questions, ask on +[Community](https://community.openfn.org) to seek assistance from the OpenFn +core team and other implementers. ::: @@ -412,123 +420,3 @@ post( callback ); ``` - -## Anonymous Functions - -Different to [Named Functions](#examples-of-adaptor-specific-functions), -Anonymous functions are generic pieces of javascript which you can write to suit -your needs. Here are some examples of these custom functions: - -### Custom replacer - -```js -field('destination__c', state => { - console.log(something); - return dataValue('path_to_data')(state).toString().replace('cats', 'dogs'); -}); -``` - -This will replace all "cats" with "dogs" in the string that lives at -`path_to_data`. - -> **NOTE:** The JavaScript `replace()` function only replaces the first instance -> of whatever argument you specify. If you're looking for a way to replace all -> instances, we suggest you use a regex like we did in the -> [example](#custom-concatenation-of-null-values) below. - -### Custom arrayToString - -```js -field("target_specie_list__c", function(state) { - return Array.apply( - null, sourceValue("$.data.target_specie_list")(state) - ).join(', ') -}), -``` - -It will take an array, and concatenate each item into a string with a ", " -separator. - -### Custom concatenation - -```js -field('ODK_Key__c', function (state) { - return dataValue('metaId')(state).concat('(', dataValue('index')(state), ')'); -}); -``` - -This will concatenate two values. - -### Concatenation of null values - -This will concatenate many values, even if one or more are null, writing them to -a field called Main_Office_City_c. - -```js -... - field("Main_Office_City__c", function(state) { - return arrayToString([ - dataValue("Main_Office_City_a")(state) === null ? "" : dataValue("Main_Office_City_a")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_b")(state) === null ? "" : dataValue("Main_Office_City_b")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_c")(state) === null ? "" : dataValue("Main_Office_City_c")(state).toString().replace(/-/g, " "), - dataValue("Main_Office_City_d")(state) === null ? "" : dataValue("Main_Office_City_d")(state).toString().replace(/-/g, " "), - ].filter(Boolean), ',') - }) -``` - -> Notice how this custom function makes use of the **regex** `/-/g` to ensure -> that all instances are accounted for (g = global search). - -### Custom Nth reference ID - -If you ever want to retrieve the FIRST object you created, or the SECOND, or the -Nth, for that matter, a function like this will do the trick. - -```js -field('parent__c', function (state) { - return state.references[state.references.length - 1].id; -}); -``` - -See how instead of taking the id of the "last" thing that was created in -Salesforce, you're taking the id of the 1st thing, or 2nd thing if you replace -"length-1" with "length-2". - -### Convert date string to standard ISO date for Salesforce - -```js -field('Payment_Date__c', function (state) { - return new Date(dataValue('payment_date')(state)).toISOString(); -}); -``` - -> **NOTE**: The output of this function will always be formatted according to -> GMT time-zone. - -### Use external ID fields for relationships during a bulk load in Salesforce - -```js -array.map(item => { - return { - Patient_Name__c: item.fullName, - 'Account.Account_External_ID__c': item.account - 'Clinic__r.Unique_Clinic_Identifier__c': item.clinicId, - 'RecordType.Name': item.type, - }; -}); -``` - -### Bulk upsert with an external ID in salesforce - -```js -bulk( - 'Visit_new__c', - 'upsert', - { - extIdField: 'commcare_case_id__c', - failOnError: true, - allowNoOp: true, - }, - dataValue('patients') -); -``` \ No newline at end of file diff --git a/docs/jobs/job-snippets.md b/docs/jobs/job-snippets.md new file mode 100644 index 00000000000..fbb54522c79 --- /dev/null +++ b/docs/jobs/job-snippets.md @@ -0,0 +1,124 @@ +--- +title: Code Snippets +sidebar_label: Code Snippets +--- + +This section includes a numner of useful JavaScript code snippets which you can +use in your jobs. + +Most snippets are implemented as callbacks to other operations. + +You can copy these callbacks and adapt them to suit your own code. + +### Custom replacer + +```js +field('destination__c', state => { + return dataValue('path_to_data')(state).toString().replace('cats', 'dogs'); +}); +``` + +This will replace all "cats" with "dogs" in the string that lives at +`path_to_data`. + +> **NOTE:** The JavaScript `replace()` function only replaces the first instance +> of whatever argument you specify. If you're looking for a way to replace all +> instances, we suggest you use a regex like we did in the +> [example](#custom-concatenation-of-null-values) below. + +### Custom arrayToString + +```js +field("target_specie_list__c", function(state) { + return Array.apply( + null, sourceValue("$.data.target_specie_list")(state) + ).join(', ') +}), +``` + +It will take an array, and concatenate each item into a string with a ", " +separator. + +### Custom concatenation + +```js +field('ODK_Key__c', function (state) { + return dataValue('metaId')(state).concat('(', dataValue('index')(state), ')'); +}); +``` + +This will concatenate two values. + +### Concatenation of null values + +This will concatenate many values, even if one or more are null, writing them to +a field called Main_Office_City_c. + +```js +... + field("Main_Office_City__c", function(state) { + return arrayToString([ + dataValue("Main_Office_City_a")(state) === null ? "" : dataValue("Main_Office_City_a")(state).toString().replace(/-/g, " "), + dataValue("Main_Office_City_b")(state) === null ? "" : dataValue("Main_Office_City_b")(state).toString().replace(/-/g, " "), + dataValue("Main_Office_City_c")(state) === null ? "" : dataValue("Main_Office_City_c")(state).toString().replace(/-/g, " "), + dataValue("Main_Office_City_d")(state) === null ? "" : dataValue("Main_Office_City_d")(state).toString().replace(/-/g, " "), + ].filter(Boolean), ',') + }) +``` + +> Notice how this custom function makes use of the **regex** `/-/g` to ensure +> that all instances are accounted for (g = global search). + +### Custom Nth reference ID + +If you ever want to retrieve the FIRST object you created, or the SECOND, or the +Nth, for that matter, a function like this will do the trick. + +```js +field('parent__c', function (state) { + return state.references[state.references.length - 1].id; +}); +``` + +See how instead of taking the id of the "last" thing that was created in +Salesforce, you're taking the id of the 1st thing, or 2nd thing if you replace +"length-1" with "length-2". + +### Convert date string to standard ISO date for Salesforce + +```js +field('Payment_Date__c', function (state) { + return new Date(dataValue('payment_date')(state)).toISOString(); +}); +``` + +> **NOTE**: The output of this function will always be formatted according to +> GMT time-zone. + +### Use external ID fields for relationships during a bulk load in Salesforce + +```js +array.map(item => { + return { + Patient_Name__c: item.fullName, + 'Account.Account_External_ID__c': item.account + 'Clinic__r.Unique_Clinic_Identifier__c': item.clinicId, + 'RecordType.Name': item.type, + }; +}); +``` + +### Bulk upsert with an external ID in salesforce + +```js +bulk( + 'Visit_new__c', + 'upsert', + { + extIdField: 'commcare_case_id__c', + failOnError: true, + allowNoOp: true, + }, + dataValue('patients') +); +``` diff --git a/docs/jobs/job-writing-guide.md b/docs/jobs/job-writing-guide.md new file mode 100644 index 00000000000..1f5a10f5a98 --- /dev/null +++ b/docs/jobs/job-writing-guide.md @@ -0,0 +1,711 @@ +--- +sidebar_label: Job Writing Guide +title: Job Writing Guide +--- + + + +Workflow automation and data integration in OpenFn is realised through the +creation of Jobs. + +This guide will walk you through key concepts and best practices for job +writing. It is suitable for new coders and experienced JavaScript programmers. +In fact, even if you're an experienced JavaScript Developer, there a number of +key patterns in the OpenFn ecosystem which it is important to learn. + +A Job is a bunch of JavaScript code which performs a particular task, like +fetching data from Salesforce or converting some JSON data to FHIR standard. + +Each job uses exactly one Adaptor (often called a "connector") to perform its +task. The Adaptor provides a collection of helper functions (Operations) which +makes it easy to communicate with a data source. + +This guide applies equally to writing Jobs on the app (Lightning) or through the +CLI. + +:::info Workflows + +Multiple jobs can be chained together in a Workflow. A common pattern is to use +one job to fetch data from datasource A, one job to convert or transform that +data to be compatible with datasource B, and a third job to upload the +transformed data to datasource B. + +To learn more about workflow design and implementation, see +[Build & Manage Workflows](build/workflows.md) + +::: + +## Operations and State + +Every job is a data transformation pipeline. + +It takes some input (a JavaScript object we call State) and executes a set of +Operations (or functions), which transform that state in series (ie, one after +the other). The final state object is returned as the output of the pipeline. + +![Job Pipeline](/img/guide-job-pipeline.webp) + +Operations are provided by an Adaptor (connector). Each adaptor exports a list +of functions designed to interact with a particular data source - for example, +take a look at the [dhis2](adaptors/packages/dhis2-docs) and +[salesforce](adaptors/packages/salesforce-docs) adaptors. + +Everything you can achieve in OpenFn can be achieve with existing JavaScript +libraries or calls to REST APIs. The value of Adaptors is that they provide +functions to make this stuff easier: taking care of authoristaion, providing +cleaner syntax, and hiding away implementation details for you. + +For example, here's how we issue a GET request with the http adaptor: + +```js +get('/patients'); +``` + +The first argument to `get` is the path to request from (the configuration will +tell the adaptor what base url to use). In this case we're passing a static +string, but we can also pass a value from state: + +```js +get(state => state.endpoint); +``` + +
+Why the arrow function? +If you've got some JavaScript experience, you'll notice The example above uses an arrow function to retreive the endpoint key from state. + +But why not just do this? + +``` +get(state.endpoint); +``` + +Well, the problem is that the state value must be resolved lazily (ie, just +before the get actually runs). Because of how Javascript works, if we just +inline the value it might read before state.endpoint has been actually been +assigned. + +For more details, jump ahead to [Reading State Lazily](#reading-state-lazily) + +
+ +Your job code should only contain Operations at the top level/scope - you should +NOT include any other JavaScript statements. We'll talk about this more in a +minute. + +## Callbacks and fn() + +Many Operations give you access to a callback function. + +Callbacks will be invoked with state, will run whatever code you like, and must +return the next state. Usually your callback will be invoked as the very last +step in an operation. + +This is useful for intercepting and manipulating the return value of a given +operation. + +
+What is a callback? +A callback is a common pattern in JavaScript. + +It's kind of hard to understand in the abstract: a callback is a function which +you pass into some other function, to be invoked by that function at a +particular time. + +It's best explained with an example. All JavaScript arrays have a function +called `map`, which takes a single callback argument. + +Array.map will iterate over every item in the array, invoke your callback +function with it, save the result to a new array, and when it's finished, it +will return that array. + +```js +const array = ['a', 'b', 'c']; +const result = array.map(item => { + return item.toUpperCase(); +}); +console.log(array); // ['a', 'b', 'c']; +console.log(result); // ['A', 'B', 'C']; +``` + +Because functions are data in JavaScript, we can we-write that code like this +(which might be a bit more readable) + +```js +const array = ['a', 'b', 'c']; +const upperCase = item => { + return item.toUpperCase(); +}; +const result = array.map(upperCase); +console.log(array); // ['a', 'b', 'c']; +console.log(result); // ['A', 'B', 'C']; +``` + +
+ +The `fn()` function, for example, ONLY allows you define a callback. This is +useful for running arbitrary code - if you want to drop down to raw JavaScript +mode, this is how you do it: + +```js +fn(state => { + // declare a help function + const convertToFhir = item => { + /* ... */ + }; + + // Map data into a new format with native Javsacript functions + state.transformed = state.data.map(convertToFhir); + + // Always return the state + return state; +}); +``` + +Many other operations provide a callback argument, in which case, your callback +will be invoked with state, and most return the final state as a result of the +operation. + +For example, say you fetch some data from a data source and get back a block of +JSON. Maybe you want to filter this data before passing it to the next +operation. + +You might naively try something like this - but it won't work! + +```js +get('/data'); // writes to state.data +state.data = state.data.filter(/* ... */); // This is invalid! +``` + +You could use another operation, like `fn` or `each` - and often these work +great! + +```js +get('/data'); +fn(state => { + state.data = state.data.filter(/* ... */); + return state; +}); +``` + +But you can also use a callback function, which is usually a bit neater: + +```js +get('/data', {}, state => { + state.data = state.data.filter(/* ... */); + return state; +}); +``` + +Whatever your callback returns will be used as the input state for the next +operation (or will be the final state for the job). So remember to ALWAYS return +state! + +Be mindful that some Adaptors will write internal information to state. So you +should usually `return { ... state }` rather than `return { data: state.data }`. + +:::tip + +Remember! Always return state from a callback. + +::: + +## Operations run at the top level + +Operations will only work when they are at the top level of your job code, like +this: + +```js +get('/patients'); +each('$.data.patients[*]', (item, index) => { + item.id = `item-${index}`; +}); +post('/patients', dataValue('patients')); +``` + +OpenFn will call your operations in series during workflow execution, ensuring +the correct state is fed into each one. + +If you try to nest an operation inside the callback of another operation, you'll +quickly run into trouble: + +```js +get('/patients', { headers: { 'content-type': 'application/json' } }, state => { + // This will fail because it is nested in a callback + each('$.data.patients[*]', (item, index) => { + item.id = `item-${index}`; + }); +}); +post('/patients', dataValue('patients')); +``` + +This is because an operation is actually a "factory" style function - when +executed, it returns a new function. That new function must be invoked with +state, and will return state. + +
+What is a factory function? +Factory functions are quite a hard pattern to understand, although you get used it. + +Luckily, you don't really need to understand the pattern to understand openfn. + +Simply put, a factory function doesn't really do anything. It instead returns a +function to do something. + +Factory functions are useful for deferred execution, lazy loading, constructor +functions, and scope binding. + +They're used by us in open function for deferred execution: our runtime converts +each factory into an actual operation function, saves all the functions into an +array, then iterates over each one and passes in state. + +
+ +The OpenFn runtime knows how to handle an operation at the top scope, it can run +it as part of the pipeline and handle state appropriately. But it does not know +how to deal with a nested operation like this. + +You should actually never need to nest an operation anyway. Just bring it to the +top level and lean in to the pipeline idea. But if you ever find yourself in a +situation where you absolutely need to use a nested operation, you should pass +state into it directly, like this: + +```js +get('/patients', { headers: { 'content-type': 'application/json' } }, state => { + each('$.data.patients[*]', (item, index) => { + item.id = `item-${index}`; + })(state); // Immediately invoke the Operation and pass state into it. This is naughty! +}); +post('/patients', dataValue('patients')); +``` + +To be clear, this is considered an anti-pattern and should not be used except in +emergencies. + +## Reading state lazily + +A common problem in OpenFn coding is getting hold of the right state value at +the right time. + +Consider this code: + +```js +get('/some-data'); +post('/some-other-data', state.data); +``` + +What it's trying to do is call the GET method on some REST service, save the +result to `state.data`, and then pass that `state.data` value into a post call +to send it somewhere else. + +Can you see the problem? + +It happens all the time. + +Because of the way JavaScript works, `state.data` will be evaluated before the +`get()` request has finished. So the post will always receive a value of +`undefined`. + + +
+Okay, how does JavaScript work? +JavaScript, like most languages, will evaluate synchronously, executing code line of code one at a time. + +Because each Operation is actually a factory function, it will execute instantly +and return a function - but that function won't actually be executed yet. + +The returned function will have the original arguments in scope (trapped as +closures), and be executed later against the latest state object. + +The example above will synchronously create two functions, which are added into +an array by the compiler (see bellow), and then excuted by the runtime. + +So when your code executes, it's doing something like this: + +```js +const getFn = get('/some-data'); +const postFn = post('/some-data', state.data); + +return getFn(state).then(nextState => postFn(nextState)); +``` + +The point is that when the post operation is created, all we've done is _create_ +the `get` function - we haven't actually _run_ it. And so state.data is +basically uninitialised. + +
+ +What we actually need to do is defer the evaluation of `state.data` until the +`post` operation actually runs. In other words, we work out the value of +`state.data` at last possible moment. + +There are a few ways we can do that. Some jobs use `dataValue`, which is neat if +a bit verbose (there are many examples in this guide), and some operations +support JSON path strings. The preferred way in modern OpenFn is to use an +inline-function: + +```js +get('/some-data'); +post('/some-other-data', state => state.data); +``` + +This passes a function into the `post` operator. When the `post()` call actually +executes, the first thing it'll do is resolve any function into arguments into +values. It does this by calling the function passing in the latest state, and +using the return as the value. + +These lazy functions are incredibly powerful. Using them effectively is the key +to writing good OpenFn jobs. + +## Mapping Objects + +A common use-case in OpenFn fn is to map/convert/transform an object from system +A to the format of system B. + +We often do this in multiple Jobs in the same workflow, so that we can use +different adaptors. But in this example we'll work with three operations in one +job with the http adaptor: one to fetch data, one to transform, and one to +upload: + +```js +// Fetch an object from one system +get('https://www.system-a.com/api/patients/123'); + +// Transform it +fn(state => { + // Read the data we fetched + const obj = state.data; + + // convert it by mapping properties from one object to the o ther + state.uploadData = { + id: obj.id, + name: `${obj.first_name} ${obj.last_name}`, + metadata: obj.user_data, + }; + + // Don't forget to return state! + return state; +}); + +// Post it elsewhere +post('https://system-b.com/api/v1/records/123', () => state.uploadData); +``` + +:::tip Batch conversions + +These examples show a single object being converted - but sometimes we need to +convert many objects at once. + +See the each() example below to see how we can do this with the each operator. + +You can also use a Javascript map() or forEach() function inside a callback or +fn block. + +Generally it's easier to spread your job logic across many top-level operations, +each responsible for one task, rather than having a few deeply nested +operations. + +::: + +This is fine - and actually, having lots of operations which each do a small +task is usually considered a good thing. It makes code more readable and easier +to reason about when things go wrong. + +But every operation argument accepts a function (allowing lazy state references, +as described above). This gives us the opportunity to the conversion in-line in +the post operation: + +```js +// Fetch an object from one system +get('https://www.system-a.com/api/patients/123'); + +// Transform and post it elsewhere +post('https://system-b.com/api/v1/records/123', state => ({ + id: state.data.id, + name: `${state.data.first_name} ${state.data.last_name}`, + metadata: state.data.user_data, +})); +``` + +Effective use of these lazily-resolved functions is critical to writing good +OpenFn jobs. + +## Iteration with each() + +A typical use-case in data integration in particular is to convert data from one +format to another. Usually this involves iterating over an array of items, +converting the values, and mapping them into a new array. + +In OpenFn, we can use the `each()` operator to do this. + +```js +each( + '$.data.items[*]', + get(state => `/patients/${state.data.id}`) +); +``` + +`each()` takes a JSON path string as its first argument, which points to some +part of state. In JSON path, we use `$` to refer to the root, and dot notation +to chain a path, and `[*]` to "select" an array of items. + +The second argument is an Operation, which will receive each item at the end of +the json path as `state.data`, but otherwise will receive the rest of the state +object. + +So we can iterate over each item and write it back to state, like this: + +```js +fn((state) => { + // Initialize an array into state to use later + state.transformed = [] + return state; +}) +each("$.items[*]", fn(state) => { + // Pull the next item off the state + const next = state.data; + + // Transform it + const transformed = { ...next }; + + // Write it back to the top-level transformed array on state + state.transformed.push(transformed) + + // Always return state + return state; +}) +``` + +Or we can pass in another operation, like this Salesforce example: + +```js +each( + '$.form.participants[*]', + upsert('Person__c', 'Participant_PID__c', state => ({ + Participant_PID__c: state.pid, + First_Name__c: state.participant_first_name, + Surname__c: state.participant_surname, + })) +); +``` + +Each participant is upserted into Salesforce, with its salesforce fields mapped +to values in the `participants` array. + +:::info JSON paths + +The use of a JSON path string as the first argument to `each()` allows the +runtime to lazily evaluate the value at that path - +[See Reading state lazily](#reading-state-lazily). + +Not all operations support a JSON path string - refer to +[individual adaptor docs](/adaptors) for guidance. + +::: + +## Variable initialisation + + + +A common pattern is to need to declare some variables at the state of the job. +These could be static values to use later, functions to be called multiple times +through the job, or bits of state that we want to return at the end. + +It is considered best practice to use an `fn()` block to do this at the start of +the job, writing all values to state. + +```js +fn(state => { + // Create an array to hold the final results of the job + state.results = []; + + // Create a lookup index to be used during the job + state.lookup = {}; + + state.keyMap = { + AccountName: 'C__Acc_Name', // capture various static mappings for transformation + }; + + state.maxPageSize = 200; // Define some config options + + state.convertToSF = item => { + /* ... */ + }; // A function to be re-used + + return state; +}); + +// the rest of your job code goes here +get('/abc'); + +fn(state => { + /* ... */ + + // Only return the results array as output from the job + return { result: state.results }; +}); +``` + +## Cleaning final state + +When your job has completed, the final state object will be "returned" by the +openfn runtime. + +This final state must be serialisable to JSON. + +If there are more steps in the workflow, the state will be passed to those +steps. If running on the app (Lightning), the final state will be saved as a +dataclip. Or if running in the CLI, the final state will be written to disk. + +It's often desirable to clean up your final state so that any unused information +is removed. This reduces the size of your saved data, but could also be an +important security consideration. + +The best way to do this is with a closing `fn()` block which returns just the +keys you want (this is usually best): + +```js +fn(state => { + return { + data: state.data, + }; +}); +``` + +You could use the spread operator to override some keys: + +```js +fn(state => { + return { + ...state, + secretStuff: null, + }; +}); +``` + +Or use the rest operator: + +```js +fn(state => { + const { usename, password, secrets, ...rest } = state; + return rest; +}); +``` + +:::info Configuration & Functions + +OpenFn will automatically scrub the `configuration` key and any functions from +your final state. + +::: + + + +## Error Handling + +If something goes wrong, it's usually best to let your jobs fail. + +Failing jobs will generate the right status on the OpenFn app and communicate +that something is wrong. + +Don't worry, errors happen all the time! Even well established workflows will +occasionally throw an error because of some unexpected data somewhere in the +pipeline. It's a fact of life, but the most important thing is to be aware of +it. + +Errors should be thrown from the job without much ceremony, and will be caught +by the runtime to be processed appropriately. + +If a Job does throw an error, it will be logged and written to the final state, +so it should be easy to find and identify the cause. + +It is common practice in a Workflow to let a Job error, and then perform some +task - like emailing a system admin that there was a problem. + +When processing batches of data, you might want to catch errors occuring on +individual items and write them to state. That way one bad item won't ruin a +whole batch, and you know which items succeeded and which failed. You can then +throw an exception to recognise that the job has failed. + +## Compilation + +The code you write isn't technically executable JavaScript. You can't just run +it through node.js. It needs to be transformed or compliled into portable +vanilla JS code. + +:::warning + +This is advanced stuff more focused at JavaScript developers and the technically +curious. These docs are not intended to be complete - just a nudge in the right +direction to help understand how jobs work. + +::: + +The major differences between openfn code and JavaScript are: + +- The top level functions in the code are executed synchronously (in sequence), + even if they contain asynchronous code +- OpenFn code does not contain import statements (although technically it can). + These are compiled in. +- Compiled code is a JavaScript ESM module which default-exports an array of + async functions. These functions are imported and executed by the runtime. + +It shouldn't be necessary to understand compilation in detail, but you should be +aware that the code you write is not the code you run. + +If you're a JavaScript developer, understanding some of these changes might help +you better understand how OpenFn works. Using the CLI, you can run +`openfn compile path/to/job.ja -a ` to see compiled code. + +Here's an example of how a simple job looks in compilation: + +This job: + +```js +get('/patients'); +``` + +Compiles to this JavaScript module: + +```js +import { get } from '@openfn/language-http'; +export * from '@openfn/language-http'; +export default [get('/patients')]; +``` + +## Next Steps + +The best way to learn how to write OpenFn jobs is to write OpenFn jobs. + +You can [get started with CLI](/documentation/cli) and start running jobs +locally. Then take a look at the [CLI Challenge](/documentation/cli-challenges) +to really exercise your job writing skills. + +If you're ready to start using the app, take a look at this guide to +[create your first Workflow](documentation/build/workflows). + +Workflow design is a non-trivial problem, so you might also like to review the +Workflow [Design Process docs](documentation/design/design-overview). + +:::info Questions? + +If you have any job-writing questions, ask on +[Community](https://community.openfn.org) to seek assistance from the OpenFn +core team and other implementers. + +::: diff --git a/docs/jobs/state.md b/docs/jobs/state.md new file mode 100644 index 00000000000..7ec4794d4f1 --- /dev/null +++ b/docs/jobs/state.md @@ -0,0 +1,123 @@ +--- +title: Input and output state +--- + +Each Job requires an input state and (in most cases) will produce an output +state. This article explains these concepts in greater detail. + +State is just a Javascript object. It is the means via which Jobs share +information between each other. It also provides a common scope for Operations +to read from and write to. + +The final state form a Job must always be a serializable Javascript object (ie, +a JSON object). Any non-serializable keys will be removed. + +![Job State Overview](/img/state-javascript.png) + +:::tip A note on terminology + +Input state is often referred to as _initial state_, and output state is often +referred as _final state_. These terms can safely be used interchangeably. + +::: + +## State Keys + +State objects tend to have the following keys: + +- `data`: a temporary information store, usually used to save the result of + particular operation +- `configuration`: an object containing credential data +- `references`: a history of previous `data` values +- `response`: often used by adaptors (like http) to save the raw http response + from a request +- `errors`: a list of errors generated by a particular Workflow, indexed by Job + name. + +At the end of a Job, the configuration key will be removed, along with any other +non serialisable keys. + +Adaptors will occasionally write extra information to state during a run - for +example, database Adaptors tend to write a `client` key to state, used to track +the database connection. These will eb removed at the end of a Job. + +## Input & output state for runs + +Depending on whether you're running Workflows locally or on the app, the input +state for a Run can be generated differently: + +- When creating a work order by hand, you must select or generate your input + manually (e.g., by creating a custom `Input` on the app or `state.json` file + if working locally [in the CLI](../build-for-developers/cli-intro.md)). +- When a work order is automatically created via a webhook trigger or cron + trigger, state will be created as described below. + +The final state of a Run is determined by what's returned from the last +operation. Remember that job expressions are a series of operations: they each +take state and return state, after creating any number of side effects. You can +control what is returned at the end of all of these operations. + +### Webhook triggered Runs + +In the app, when a Run is triggered by a webhook, the input state contains +important parts of the inbound **http request**. + +The input state will look something like this: + +```js +{ + data: {}, // the body of the http request + request: { + headers: {} // an object containing the headers of the request + }, +} +``` + +### Cron triggered Runs + +In the app, when a Run is triggered by a cron job, the input state will the +final state of the **last succesful run** for this workflow. + +If this is the first time the workflow has run, the initial state will simply by +an empty object. + +```js +{ + ...(finalStateOfLastSuccessfulRun || {}), +} +``` + +## Input & output state for steps + +State is also passed between each step in a workflow. The output state of the +previous step is used as the input state for the next step. + +### On success + +When a job succeeds, its output state will be whatever is returned by the last +operation. + +```js +{ + data: { patients: [ ]}, + references: [1, 2, 3] +} +``` + +### On failure + +When a step in a workflow fails, the error will be added to an `errors` object +on state, keyed by the ID of the job that failed. + +```js +{ + data: { patients: [ ]}, + references: [1, 2, 3], + errors: { jobId: { /* error details */} } +} +``` + +See the below diagram for a visual description of how state might be passed +between Steps in a Workflow. + +![Passing State](/img/passing-state-steps.png) diff --git a/docs/tutorials/http-to-googlesheets.md b/docs/tutorials/http-to-googlesheets.md index 206ba166f5e..52fc900a8d4 100644 --- a/docs/tutorials/http-to-googlesheets.md +++ b/docs/tutorials/http-to-googlesheets.md @@ -73,7 +73,7 @@ following options Check out the docs on the ["http" Adaptor](/adaptors/packages/http-readme), [configuring Steps](../build/steps/steps.md), and -[job-writing](../build/steps/jobs.md). +[job-writing](../jobs/job-writing-guide.md). ::: diff --git a/docs/tutorials/kobo-to-dhis2.md b/docs/tutorials/kobo-to-dhis2.md index 551a71fdf30..c48f593736e 100644 --- a/docs/tutorials/kobo-to-dhis2.md +++ b/docs/tutorials/kobo-to-dhis2.md @@ -75,7 +75,7 @@ getSubmissions({ formId: 'aBpweTNdaGJQFb5EBBwUeo' }); Check out the docs on the ["kobotoolbox" Adaptor](/adaptors/kobotoolbox), [configuring Steps](../build/steps/steps.md), and -[job-writing](../build/steps/jobs.md). +[job-writing](../jobs/job-writing-guide.md). ::: @@ -121,7 +121,7 @@ fn(state => { Check out the docs on the ["common" Adaptor](/adaptors/packages/common-docs), [configuring Steps](../build/steps/steps.md), and -[job-writing](../build/steps/jobs.md). +[job-writing](../jobs/job-writing-guide.md). ::: @@ -182,7 +182,7 @@ create('dataValueSets', state => ({ Check out the docs on the ["dhis2" Adaptor](/adaptors/dhis2), [configuring Steps](../build/steps/steps.md), and -[job-writing](../build/steps/jobs.md). +[job-writing](../jobs/job-writing-guide.md). ::: diff --git a/sidebars-main.js b/sidebars-main.js index a8dd23ec628..9f7282b5ee6 100644 --- a/sidebars-main.js +++ b/sidebars-main.js @@ -36,7 +36,17 @@ module.exports = { 'design/workflow-specs', ], }, - + { + type: 'category', + label: 'Write Jobs', + items: [ + 'jobs/job-writing-guide', + 'jobs/state', + 'jobs/javascript', + 'jobs/job-examples', + 'jobs/job-snippets', + ], + }, { type: 'category', label: 'Platform App ⚡', @@ -53,21 +63,9 @@ module.exports = { 'build/paths', 'build/credentials', 'build/limits', + 'build/editing-locally', + 'build/working-with-branches', 'build/troubleshooting', - { - type: 'category', - label: 'Jobs', - items: [ - 'build/steps/jobs', - 'build/steps/job-examples', - 'build/steps/operations', - 'build/steps/multiple-operations', - 'build/steps/state', - 'build/steps/each', - 'build/steps/editing-locally', - 'build/steps/working-with-branches', - ], - }, ], }, { @@ -116,9 +114,6 @@ module.exports = { 'build-for-developers/cli-usage', 'build-for-developers/cli-walkthrough', 'build-for-developers/cli-challenges', - // 'build-for-developers/jobs', - // 'build-for-developers/build-with-api', - // 'build-for-developers/security-for-devs', ], }, { diff --git a/static/img/guide-job-pipeline.webp b/static/img/guide-job-pipeline.webp new file mode 100644 index 00000000000..f2baac36579 Binary files /dev/null and b/static/img/guide-job-pipeline.webp differ