Skip to content

Commit 49caf75

Browse files
authored
New Workflow Generator (#1034)
* use ohmjs to build a workflow parser * update tests * support multiple pairs * more tests * unstable commit: testing props * get a single prop working * support multiple props * tidy up * add project generation util * return proper Workflow instance * support attributes on workflows * support comments * docs * update tests, allow prop values to be written into strings * update tests * changeset
1 parent b8a2ff8 commit 49caf75

16 files changed

Lines changed: 968 additions & 540 deletions

.changeset/ten-hoops-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openfn/project': minor
3+
---
4+
5+
Add project and workflow generator utility

packages/project/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
## openfn/project
2+
3+
A package to track, parse and serialize OpenFn project definitions.
4+
5+
A PROJECT is defined as a set of connected workflows with a single billing account, like a project in the app.
6+
7+
A single Project can be Checked Out to disk at a time, meaning its source workflows and expressions will be expanded nicely onto the file system.
8+
9+
A Workspace is a set of related Projects , including a Project and its associated Sandboxes, or a Project deployed to apps in multiple web domains
10+
11+
### Serializing and Parsing
12+
13+
The main idea of Projects is that a Project represents a set of OpenFn workflows defined in any format and present a standard JS-friendly interface to manipulate and reason about them.
14+
15+
The from/to serializers are designed to support the following formats:
16+
17+
- Projects expanded to the file system (through CLI or hand-written)
18+
- v1 JSON state files generated by `openfn pull`
19+
- v2 Project files (basically v1 state with some extra props)
20+
21+
Serializers and parsers also support JSON and YAML formats interchangeably.
22+
23+
### Project & Workflow Generation
24+
25+
`project` exports utility functions to generate Projects and Workflows from a simple syntax. This is useful for testing.
26+
27+
Really it's a Workflow generator, but you can have it wrapped in a Project if you like.
28+
29+
Use it like this:
30+
31+
```
32+
import { generateProject, generateWorkflow } from '@openfn/project'
33+
import type { Project, Workflow } from '@openfn/project'
34+
35+
const proj: Project = generateProject('my-project', ['a-b b-c'])
36+
const wfL Workflow = generateWorkflow('a-b b-c')
37+
```
38+
39+
Project generation uses a simple string language to represent a workflow structure:
40+
41+
Define nodes in pairs seperated by a dash (no whitespace)
42+
43+
```
44+
a-b # parent-child
45+
```
46+
47+
For multiple children, define multiple pairs:
48+
49+
```
50+
a-b
51+
a-c
52+
```
53+
54+
You can set properties on the workflow itself - probably the name, with `@attributes`
55+
56+
```
57+
@name my-cool-workflow
58+
```
59+
60+
You can also set properties on a node by putting comma seperated key-value pairs in brackets
61+
62+
```
63+
a(adaptor=http)
64+
```
65+
66+
You can use quotes to include spaces and brackets in a property value - great for expressions:
67+
68+
```
69+
a(expression="fn(s => s)")
70+
```
71+
72+
You can comment inside the string with `#`, which is a basic single-line comment
73+
74+
Reference:
75+
76+
```
77+
# Comments behind hashes
78+
@attribute-name attribute-value
79+
parent(propName=propValue,x=y)-child
80+
a-b # can comment here to
81+
```

packages/project/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
"@openfn/lexicon": "workspace:^",
3939
"@openfn/logger": "workspace:*",
4040
"glob": "^11.0.2",
41+
"lodash": "^4.17.21",
4142
"lodash-es": "^4.17.21",
43+
"ohm-js": "^17.2.1",
4244
"yaml": "^2.2.2"
4345
},
4446
"files": [

packages/project/src/Workflow.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as l from '@openfn/lexicon';
2+
import slugify from './util/slugify';
23

34
const clone = (obj) => JSON.parse(JSON.stringify(obj));
45

@@ -29,8 +30,8 @@ class Workflow {
2930
this.workflow = clone(workflow);
3031

3132
const { id, name, openfn, steps, ...options } = workflow;
32-
this.id = id;
33-
this.name = name;
33+
this.id = id ?? slugify(name);
34+
this.name = name ?? id;
3435
this.openfn = openfn;
3536
this.options = options;
3637

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { randomUUID } from 'node:crypto';
2+
import path from 'node:path';
3+
import { readFileSync } from 'node:fs';
4+
import { grammar } from 'ohm-js';
5+
import Project from '../Project';
6+
import Workflow from '../Workflow';
7+
import slugify from '../util/slugify';
8+
9+
type GenerateWorkflowOptions = {
10+
name: string;
11+
uuidSeed: number;
12+
openfnUuid: boolean;
13+
printErrors: boolean; // true by default
14+
};
15+
16+
let parser;
17+
18+
const initOperations = (options = {}) => {
19+
let nodes = {};
20+
21+
// Sets values available to this inside semantic actions
22+
const uuid = () => {
23+
return options.uuidSeed ? options.uuidSeed++ : randomUUID();
24+
};
25+
26+
const buildNode = (name: string) => {
27+
if (!nodes[name]) {
28+
nodes[name] = {
29+
name: name,
30+
id: slugify(name),
31+
openfn: {
32+
uuid: uuid(),
33+
},
34+
};
35+
}
36+
return nodes[name];
37+
};
38+
39+
// These are functions which run on matched parse trees
40+
const operations = {
41+
Workflow(attrs, pair) {
42+
pair.children.forEach((child) => child.buildWorkflow());
43+
44+
const steps = Object.values(nodes);
45+
46+
const attributes = attrs.children
47+
.map((c) => c.buildWorkflow())
48+
.reduce((obj, next) => {
49+
const [key, value] = next;
50+
obj[key] = value;
51+
return obj;
52+
}, {});
53+
54+
return { ...attributes, steps: steps };
55+
},
56+
comment(_a, _b) {
57+
return null;
58+
},
59+
attribute(_, name, _space, value) {
60+
return [name.sourceString, value.sourceString];
61+
},
62+
Pair(parent, edge, child) {
63+
const n1 = parent.buildWorkflow();
64+
const n2 = child.buildWorkflow();
65+
const e = edge.buildWorkflow();
66+
67+
n1.next ??= {};
68+
69+
n1.next[n2.name] = e;
70+
71+
return [n1, n2];
72+
},
73+
// node could just be a node name, or a node with props
74+
// different results have different requirements
75+
// Not sure the best way to handle this, but this seems to work
76+
node(node) {
77+
if (node._node.ruleName === 'node_name') {
78+
return buildNode(node.sourceString);
79+
}
80+
return node.buildWorkflow();
81+
},
82+
nodeWithProps(nameNode, props) {
83+
const name = nameNode.sourceString;
84+
const node = buildNode(name);
85+
props.buildWorkflow().forEach(([key, value]) => {
86+
nodes[name][key] = value;
87+
});
88+
return node;
89+
},
90+
node_name(n) {
91+
return n.sourceString;
92+
},
93+
props(_lbr, props, _rbr) {
94+
return props.asIteration().children.map((c) => c.buildWorkflow());
95+
},
96+
prop(key, _op, value) {
97+
if (value._iter) {
98+
console.log('>>>> ITER');
99+
}
100+
return [key.sourceString, value.buildWorkflow()];
101+
},
102+
// Bit flaky - we need this to handle quoted props
103+
_iter(...items) {
104+
return items.map((i) => i.buildWorkflow()).join('');
105+
},
106+
alnum(a) {
107+
return a.sourceString;
108+
},
109+
quotedProp(_left, value, _right) {
110+
return value.sourceString;
111+
},
112+
edge(_) {
113+
return {
114+
openfn: {
115+
uuid: uuid(),
116+
},
117+
};
118+
},
119+
};
120+
121+
return operations;
122+
};
123+
124+
export const createParser = () => {
125+
// Load the grammar
126+
// TODO: is there any way I can compile/serialize the grammar into JS?
127+
const contents = readFileSync(path.resolve('src/gen/workflow.ohm'), 'utf-8');
128+
const parser = grammar(contents);
129+
130+
return {
131+
parse(str, options) {
132+
const { printErrors = true } = options;
133+
134+
// Setup semantic actions (which run against an AST and build stuff)
135+
// Do this on each parse so we can maintain state
136+
const semantics = parser.createSemantics();
137+
138+
semantics.addOperation('buildWorkflow', initOperations(options));
139+
140+
// First we parse the source
141+
const result = parser.match(str);
142+
if (!result.succeeded()) {
143+
if (printErrors) {
144+
console.error(result.shortMessage);
145+
console.error(result.message);
146+
}
147+
// TODO can we be more helpful here?
148+
throw new Error('Parsing failed!' + result.shortMessage);
149+
}
150+
151+
// Then we pass the AST into an operation factory
152+
const adaptor = semantics(result);
153+
154+
// Finally we trigger a semantic action to build a workflow
155+
return adaptor.buildWorkflow();
156+
},
157+
};
158+
};
159+
160+
/**
161+
* Generate a Workflow from a simple text based representation
162+
* eg, `a-b b-c a-c`
163+
*/
164+
function generateWorkflow(
165+
def: string,
166+
options: Partial<GenerateWorkflowOptions> = {}
167+
) {
168+
if (!parser) {
169+
parser = createParser();
170+
}
171+
172+
const wf = new Workflow(parser.parse(def, options));
173+
if (options.openfnUuid) {
174+
wf.openfn = {
175+
uuid: randomUUID(),
176+
};
177+
}
178+
return wf;
179+
}
180+
181+
function generateProject(
182+
name: string,
183+
workflowDefs: string[],
184+
options: Partial<GenerateWorkflowOptions>
185+
) {
186+
const workflows = workflowDefs.map((w) => generateWorkflow(w, options));
187+
188+
return new Project({
189+
name,
190+
workflows,
191+
});
192+
}
193+
194+
export default generateWorkflow;
195+
196+
export { generateWorkflow, generateProject };

packages/project/src/gen/workflow-generator.ts

Lines changed: 0 additions & 85 deletions
This file was deleted.

0 commit comments

Comments
 (0)