@izara_project/izara-core-generate-service-code 1.0.63 → 1.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. package/package.json +1 -1
  2. package/src/codeGenerators/app/sls_yaml/FunctionYamlGenerator.js +10 -0
  3. package/src/codeGenerators/app/sls_yaml/SharedResourceYamlGenerator.js +3 -4
  4. package/src/codeGenerators/app/sls_yaml/_policy/PolicyRegistry.js +1 -39
  5. package/src/codeGenerators/app/sls_yaml/_policy/__tests__/PolicyRegistry.test.js +1 -16
  6. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/events/BaseDsqHandler.js +10 -2
  7. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/events/__tests__/BaseDsqHandler.test.js +5 -4
  8. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/events/eventBridge.js +12 -4
  9. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/events/lambdaSyncApi.js +10 -2
  10. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/events/lambdaSyncInv.js +10 -2
  11. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/shared/flowValidator.js +138 -1
  12. package/src/codeGenerators/app/src/generatedCode/Flow/_shared/shared/propertyNormalizer.js +15 -17
  13. package/src/generateCode.js +0 -1
  14. package/src/schemaGenerators/app/src/schemas/RelationshipSchemas/AttributeTreeRelationshipSchemaGenerator.js +1 -41
  15. package/src/schemaGenerators/app/src/schemas/RelationshipSchemas/PropertyValueRelationshipSchemaGenerator.js +1 -41
  16. package/src/schemaGenerators/app/src/schemas/RelationshipSchemas/__tests__/relationshipSchemaHelpers.test.js +38 -0
  17. package/src/schemaGenerators/app/src/schemas/RelationshipSchemas/relationshipSchemaHelpers.js +40 -0
  18. package/src/codeGenerators/app/sls_yaml/_policy/PolicyEmitter.js +0 -46
  19. package/src/codeGenerators/app/sls_yaml/_policy/__tests__/PolicyEmitter.test.js +0 -55
  20. package/src/codeGenerators/app/sls_yaml/_policy/templates/ManagedPolicy_Yaml.ejs +0 -22
  21. package/test_schema.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izara_project/izara-core-generate-service-code",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "Code for locally generating per service files",
5
5
  "homepage": "https://bitbucket.org/izara-core-support-services/izara-core-support-services-generate-service-code#readme",
6
6
  "repository": {
@@ -118,6 +118,16 @@ export async function generateFunctionYaml(allSchemas, options) {
118
118
  const handlerBaseName = splitFileName[0]; // e.g. ProcessCreate or CreateComplete
119
119
  const handlerSuffix = splitFileName[1]; // e.g. HdrSqs, HdrApi, HdrInv
120
120
 
121
+ // Skip custom handlers that self-register their own YAML to prevent duplicates
122
+ if (
123
+ handlerBaseName.startsWith('ProcessTriggerCache') ||
124
+ handlerBaseName.startsWith('CheckTriggerCache') ||
125
+ handlerBaseName.startsWith('TriggerCache') ||
126
+ handlerBaseName.startsWith('CompleteStatusField')
127
+ ) {
128
+ continue;
129
+ }
130
+
121
131
  const isCrud = isCrudFlow(flowTag);
122
132
  const isRel = isRelationshipFlow(flowTag);
123
133
  const isRbac =
@@ -459,10 +459,9 @@ export async function generateSharedResourceYaml(allSchemas, options) {
459
459
  );
460
460
 
461
461
  for (const roleName of allowedRoles) {
462
- policyRegistry.addMany(
463
- roleName,
464
- buildRoleStatements(roleName, resources, scopedResources)
465
- );
462
+ for (const statement of buildRoleStatements(roleName, resources, scopedResources)) {
463
+ policyRegistry.add(roleName, statement.action, statement.resource);
464
+ }
466
465
  }
467
466
 
468
467
  const groupedStatements = policyRegistry.toGroupedByRole();
@@ -1,9 +1,6 @@
1
1
  // src/codeGenerators/app/sls_yaml/_policy/PolicyRegistry.js
2
2
  //
3
- // In-memory IAM permission registry. Decoupled generators register
4
- // (roleName, action, resource) tuples at any point during generation.
5
- // PolicyEmitter reads the registry after all generators have run and
6
- // emits one AWS::IAM::ManagedPolicy per role with deduplicated statements.
3
+ // In-memory IAM permission registry for the shared-resource generator.
7
4
  //
8
5
  // Internal shape:
9
6
  // Map<roleName, Map<action, Set<resource>>>
@@ -15,48 +12,18 @@ export class PolicyRegistry {
15
12
  constructor() {
16
13
  /** @type {Map<string, Map<string, Set<string>>>} */
17
14
  this._roles = new Map();
18
- /** @type {Set<string>} Roles whose entries have been fully replaced via .set() */
19
- this._replaced = new Set();
20
15
  }
21
16
 
22
17
  add(roleName, action, resource) {
23
- if (this._replaced.has(roleName)) {
24
- throw new Error(
25
- `PolicyRegistry.add("${roleName}", ...): role was previously replaced via .set(); ` +
26
- `use .set() again or call .resetRole() first.`
27
- );
28
- }
29
18
  if (!this._roles.has(roleName)) this._roles.set(roleName, new Map());
30
19
  const actions = this._roles.get(roleName);
31
20
  if (!actions.has(action)) actions.set(action, new Set());
32
21
  actions.get(action).add(resource);
33
22
  }
34
23
 
35
- addMany(roleName, perms) {
36
- for (const p of perms) this.add(roleName, p.action, p.resource);
37
- }
38
-
39
- /** Fully replace a role's permission set (discards anything previously added). */
40
- set(roleName, perms) {
41
- this._roles.set(roleName, new Map());
42
- this._replaced.add(roleName);
43
- for (const p of perms) {
44
- const actions = this._roles.get(roleName);
45
- if (!actions.has(p.action)) actions.set(p.action, new Set());
46
- actions.get(p.action).add(p.resource);
47
- }
48
- }
49
-
50
- /** Forget all registrations for a role (allows re-add). */
51
- resetRole(roleName) {
52
- this._roles.delete(roleName);
53
- this._replaced.delete(roleName);
54
- }
55
-
56
24
  /** Forget every role. Used between test cases or if a caller wants a clean slate. */
57
25
  clear() {
58
26
  this._roles.clear();
59
- this._replaced.clear();
60
27
  }
61
28
 
62
29
  /**
@@ -79,11 +46,6 @@ export class PolicyRegistry {
79
46
  }
80
47
  return out;
81
48
  }
82
-
83
- /** List of role names currently registered (useful for the emitter to iterate). */
84
- registeredRoles() {
85
- return [...this._roles.keys()];
86
- }
87
49
  }
88
50
 
89
51
  /**
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from '@jest/globals';
2
- import { policyRegistry, PolicyRegistry } from '../PolicyRegistry.js';
2
+ import { PolicyRegistry } from '../PolicyRegistry.js';
3
3
 
4
4
  describe('PolicyRegistry', () => {
5
5
  let reg;
@@ -19,21 +19,6 @@ describe('PolicyRegistry', () => {
19
19
  expect(stmt.resource).toEqual(['arn:aws:s3:::a/*', 'arn:aws:s3:::b/*']);
20
20
  });
21
21
 
22
- it('addMany() inserts batch in one call', () => {
23
- reg.addMany('Role1', [
24
- { action: 's3:GetObject', resource: 'arn:aws:s3:::a/*' },
25
- { action: 's3:ListBucket', resource: 'arn:aws:s3:::a' }
26
- ]);
27
- expect(reg.toGroupedByRole().Role1.statements).toHaveLength(2);
28
- });
29
-
30
- it('set() replaces baseline for a role (full override)', () => {
31
- reg.add('Role1', 's3:GetObject', 'arn:aws:s3:::a/*'); // baseline
32
- reg.set('Role1', [{ action: 'dynamodb:GetItem', resource: 'arn:aws:dynamodb:*:*:table/x' }]);
33
- expect(reg.toGroupedByRole().Role1.statements).toHaveLength(1);
34
- expect(reg.toGroupedByRole().Role1.statements[0].action).toEqual(['dynamodb:GetItem']);
35
- });
36
-
37
22
  it('toGroupedByRole() returns empty object when nothing registered', () => {
38
23
  expect(reg.toGroupedByRole()).toEqual({});
39
24
  });
@@ -1,17 +1,25 @@
1
1
  import ejs from 'ejs';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
+ import { isCrudFlow, isRbacFlow, isRelationshipFlow } from '../shared/flowClassifier.js';
5
+ import { ownTopicFunctionName, processFunctionName } from '../shared/flowNaming.js';
4
6
  import { normalizePropertyType } from '../shared/propertyNormalizer.js';
5
7
 
6
8
  export async function generateDsqHandler(flow, stepName, suffix, flowOutputDir, mainFileName, templates) {
7
9
  const upperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1);
8
10
  const flowTag = flow.flowTag;
9
11
 
12
+ const isOwnTopic = !stepName && !isCrudFlow(flowTag) && !isRbacFlow(flowTag) && !isRelationshipFlow(flowTag) && flow.event && flow.event.includes('ownTopic');
13
+
10
14
  // For entry point stepName is empty
11
- const handlerFileName = stepName ? `${flowTag}${stepName}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`;
15
+ const handlerFileName = stepName
16
+ ? `${flowTag}${stepName}_${suffix}`
17
+ : (isOwnTopic ? `${upperCase(flowTag)}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`);
12
18
 
13
19
  // Function name: matches default export in the referenced _Main.js
14
- const functionName = stepName ? `${flowTag}${stepName}` : `Process${upperCase(flowTag)}`;
20
+ const functionName = stepName
21
+ ? `${flowTag}${stepName}`
22
+ : (isOwnTopic ? ownTopicFunctionName(flowTag) : processFunctionName(flowTag));
15
23
 
16
24
  // Queue name: keep the full handler suffix so handler code matches
17
25
  const queueNameSuffix = suffix;
@@ -41,12 +41,13 @@ describe('generateDsqHandler', () => {
41
41
  const generatedContent = await fs.readFile(generatedFilePath, 'utf-8');
42
42
 
43
43
  // Verify it contains setValidatorSchema on line 39 (approx or exactly depending on formatting, let's verify substring)
44
- expect(generatedContent).toContain('middleware.setValidatorSchema(recordHandlerSharedLib.baseValidatorSchema(');
45
- expect(generatedContent).toContain('type: "object"');
44
+ expect(generatedContent).toContain('middleware.setValidatorSchema(');
45
+ expect(generatedContent).toContain('recordHandlerSharedLib.baseValidatorSchema({');
46
+ expect(generatedContent).toContain('type: \'object\'');
46
47
  expect(generatedContent).toContain('required: [\'body\', \'messageAttributes\']');
47
48
 
48
49
  // Verify it contains reformatDsqMessage
49
- expect(generatedContent).toContain('record = recordHandlerSharedLib.reformatDsqMessage(record._izContext, record);');
50
+ expect(generatedContent).toContain('record = recordHandlerSharedLib.reformatDsqMessage(');
50
51
  expect(generatedContent).toContain('record._izContext.logger.debug(\'record ReceiveMsgOutHdrSqs\', record);');
51
52
  });
52
53
 
@@ -88,6 +89,6 @@ describe('generateDsqHandler', () => {
88
89
  // Verify template rendering and function names
89
90
  expect(generatedContent).toContain('import createOrderStepOne from \'./CreateOrderStepOne_Main.js\';');
90
91
  expect(generatedContent).toContain('createOrderStepOne');
91
- expect(generatedContent).toContain('"createOrderStepOneHdrDsq"');
92
+ expect(generatedContent).toContain('\'createOrderStepOneHdrDsq\'');
92
93
  });
93
94
  });
@@ -1,17 +1,25 @@
1
1
  import ejs from 'ejs';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
+ import { isCrudFlow, isRbacFlow, isRelationshipFlow } from '../shared/flowClassifier.js';
5
+ import { ownTopicFunctionName, processFunctionName } from '../shared/flowNaming.js';
4
6
 
5
7
  export default async function generate(flow, stepName, flowOutputDir, mainFileName, templates) {
6
8
  const upperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1);
7
9
  const flowTag = flow.flowTag;
8
10
 
9
-
11
+ const isOwnTopic = !stepName && !isCrudFlow(flowTag) && !isRbacFlow(flowTag) && !isRelationshipFlow(flowTag) && flow.event && flow.event.includes('ownTopic');
10
12
 
11
13
  const suffix = 'HdrInv';
12
- const handlerFileName = stepName ? `${flowTag}${stepName}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`;
13
- const queueName = stepName ? `${upperCase(flowTag)}${upperCase(stepName)}${upperCase(suffix)}` : `Process${upperCase(flowTag)}${upperCase(suffix)}`;
14
- const functionName = stepName ? `${flowTag}${stepName}` : `Process${upperCase(flowTag)}`;
14
+ const handlerFileName = stepName
15
+ ? `${flowTag}${stepName}_${suffix}`
16
+ : (isOwnTopic ? `${upperCase(flowTag)}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`);
17
+ const queueName = stepName
18
+ ? `${upperCase(flowTag)}${upperCase(stepName)}${upperCase(suffix)}`
19
+ : (isOwnTopic ? `${upperCase(flowTag)}${upperCase(suffix)}` : `Process${upperCase(flowTag)}${upperCase(suffix)}`);
20
+ const functionName = stepName
21
+ ? `${flowTag}${stepName}`
22
+ : (isOwnTopic ? ownTopicFunctionName(flowTag) : processFunctionName(flowTag));
15
23
 
16
24
  const handlerContent = ejs.render(templates.inv, {
17
25
  flowTag: flowTag,
@@ -1,18 +1,26 @@
1
1
  import ejs from 'ejs';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
+ import { isCrudFlow, isRbacFlow, isRelationshipFlow } from '../shared/flowClassifier.js';
5
+ import { ownTopicFunctionName, processFunctionName } from '../shared/flowNaming.js';
4
6
  import { normalizePropertyType } from '../shared/propertyNormalizer.js';
5
7
 
6
8
  export default async function generate(flow, stepName, flowOutputDir, mainFileName, templates) {
7
9
  const upperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1);
8
10
  const flowTag = flow.flowTag;
9
11
 
12
+ const isOwnTopic = !stepName && !isCrudFlow(flowTag) && !isRbacFlow(flowTag) && !isRelationshipFlow(flowTag) && flow.event && flow.event.includes('ownTopic');
13
+
10
14
  const suffix = 'HdrApi';
11
- const handlerFileName = stepName ? `${flowTag}${stepName}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`;
15
+ const handlerFileName = stepName
16
+ ? `${flowTag}${stepName}_${suffix}`
17
+ : (isOwnTopic ? `${upperCase(flowTag)}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`);
12
18
  // Queue name: ${flowTag}${flowStepName}${HandlerType}
13
19
  const queueName = stepName ? `${flowTag}${stepName}${suffix}` : `${flowTag}${suffix}`;
14
20
  // Function name matches default export of the referenced _Main.js
15
- const functionName = stepName ? `${flowTag}${stepName}` : `Process${upperCase(flowTag)}`;
21
+ const functionName = stepName
22
+ ? `${flowTag}${stepName}`
23
+ : (isOwnTopic ? ownTopicFunctionName(flowTag) : processFunctionName(flowTag));
16
24
 
17
25
  let startConfig = null;
18
26
  if (!stepName && flow.flowSteps) {
@@ -1,18 +1,26 @@
1
1
  import ejs from 'ejs';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
+ import { isCrudFlow, isRbacFlow, isRelationshipFlow } from '../shared/flowClassifier.js';
5
+ import { ownTopicFunctionName, processFunctionName } from '../shared/flowNaming.js';
4
6
  import { normalizePropertyType } from '../shared/propertyNormalizer.js';
5
7
 
6
8
  export default async function generate(flow, stepName, flowOutputDir, mainFileName, templates) {
7
9
  const upperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1);
8
10
  const flowTag = flow.flowTag;
9
11
 
12
+ const isOwnTopic = !stepName && !isCrudFlow(flowTag) && !isRbacFlow(flowTag) && !isRelationshipFlow(flowTag) && flow.event && flow.event.includes('ownTopic');
13
+
10
14
  const suffix = 'HdrInv';
11
- const handlerFileName = stepName ? `${flowTag}${stepName}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`;
15
+ const handlerFileName = stepName
16
+ ? `${flowTag}${stepName}_${suffix}`
17
+ : (isOwnTopic ? `${upperCase(flowTag)}_${suffix}` : `Process${upperCase(flowTag)}_${suffix}`);
12
18
  // Queue name: ${flowTag}${flowStepName}${HandlerType}
13
19
  const queueName = stepName ? `${flowTag}${stepName}${suffix}` : `${flowTag}${suffix}`;
14
20
  // Function name matches default export of the referenced _Main.js
15
- const functionName = stepName ? `${flowTag}${stepName}` : `Process${upperCase(flowTag)}`;
21
+ const functionName = stepName
22
+ ? `${flowTag}${stepName}`
23
+ : (isOwnTopic ? ownTopicFunctionName(flowTag) : processFunctionName(flowTag));
16
24
 
17
25
  let startConfig = null;
18
26
  if (!stepName && flow.flowSteps) {
@@ -1,3 +1,5 @@
1
+ import { TYPE_MAP } from './propertyNormalizer.js';
2
+
1
3
  const ALLOWED_EVENTS = ['ownTopic', 'extTopic', 'lambdaSyncInv', 'lambdaSyncApi', 'eventBridge', 's3', 'dsq'];
2
4
  const MAIN_ASYNC_EVENTS = ['ownTopic', 'extTopic', 's3'];
3
5
  const RESERVED_SYSTEM_FLOW_TAGS = new Set([
@@ -48,6 +50,116 @@ function validateNamedRefs(flowTag, stepName, refs, defs, label, errors) {
48
50
  }
49
51
  }
50
52
 
53
+ function validateStepPropertyTypes(flowTag, propDefs, label, errors) {
54
+ if (!propDefs || typeof propDefs !== 'object') return;
55
+ const ALLOWED_TYPES = Object.keys(TYPE_MAP);
56
+ for (const [key, def] of Object.entries(propDefs)) {
57
+ if (!def || typeof def !== 'object') continue;
58
+ if (!def.type) {
59
+ errors.push(`Flow '${flowTag}' ${label} '${key}' is missing required field 'type'.`);
60
+ continue;
61
+ }
62
+ if (!ALLOWED_TYPES.includes(def.type)) {
63
+ errors.push(
64
+ `Flow '${flowTag}' ${label} '${key}' has unsupported type '${def.type}'. Allowed types are: ${ALLOWED_TYPES.join(', ')}.`
65
+ );
66
+ continue;
67
+ }
68
+ // enum: must supply enumValues array
69
+ if (def.type === 'enum') {
70
+ if (!Array.isArray(def.enumValues) || def.enumValues.length === 0) {
71
+ errors.push(
72
+ `Flow '${flowTag}' ${label} '${key}' has type 'enum' but is missing a non-empty 'enumValues' array.`
73
+ );
74
+ }
75
+ }
76
+ // default: must match resolved primitive type
77
+ // 'mixed' type accepts any value, so skip the type check
78
+ if (def.default !== undefined && def.type !== 'mixed') {
79
+ const resolvedPrimitive = TYPE_MAP[def.type];
80
+ const actualType = Array.isArray(def.default) ? 'array' : typeof def.default;
81
+ const expectedType = resolvedPrimitive === 'array' ? 'array' : resolvedPrimitive;
82
+ if (expectedType && actualType !== expectedType) {
83
+ errors.push(
84
+ `Flow '${flowTag}' ${label} '${key}' has a 'default' value of type '${actualType}' but expected '${expectedType}' (from schema type '${def.type}').`
85
+ );
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Warn about stepProperties / stepMessageAttributes keys that are declared
93
+ * but never referenced in any step's properties or messageAttributes arrays.
94
+ */
95
+ function validateUnusedStepProperties(flowTag, flow, errors) {
96
+ const stepProps = flow.stepProperties;
97
+ const stepMsgAttrs = flow.stepMessageAttributes;
98
+ if (!stepProps && !stepMsgAttrs) return;
99
+
100
+ const usedProps = new Set();
101
+ const usedAttrs = new Set();
102
+
103
+ const flowSteps = Array.isArray(flow.flowSteps) ? flow.flowSteps : [];
104
+ for (const step of flowSteps) {
105
+ for (const ref of Array.isArray(step.properties) ? step.properties : []) {
106
+ if (typeof ref === 'string') usedProps.add(ref);
107
+ }
108
+ for (const ref of Array.isArray(step.messageAttributes) ? step.messageAttributes : []) {
109
+ if (typeof ref === 'string') usedAttrs.add(ref);
110
+ }
111
+ }
112
+
113
+ const firstStep = flowSteps[0];
114
+ if (flow.outputTopic === true && firstStep && firstStep.returnValues) {
115
+ for (const ref of Array.isArray(firstStep.returnValues.properties) ? firstStep.returnValues.properties : []) {
116
+ if (typeof ref === 'string') usedProps.add(ref);
117
+ }
118
+ for (const ref of Array.isArray(firstStep.returnValues.messageAttributes) ? firstStep.returnValues.messageAttributes : []) {
119
+ if (typeof ref === 'string') usedAttrs.add(ref);
120
+ }
121
+ }
122
+
123
+ if (stepProps && typeof stepProps === 'object') {
124
+ for (const key of Object.keys(stepProps)) {
125
+ if (!usedProps.has(key)) {
126
+ errors.push(
127
+ `Flow '${flowTag}' stepProperties key '${key}' is declared but never referenced in any step's 'properties'.`
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ if (stepMsgAttrs && typeof stepMsgAttrs === 'object') {
134
+ for (const key of Object.keys(stepMsgAttrs)) {
135
+ if (!usedAttrs.has(key)) {
136
+ errors.push(
137
+ `Flow '${flowTag}' stepMessageAttributes key '${key}' is declared but never referenced in any step's 'messageAttributes'.`
138
+ );
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Detect keys that appear in both stepProperties and stepMessageAttributes
146
+ * to prevent ambiguous naming.
147
+ */
148
+ function validateDuplicateStepPropertyKeys(flowTag, flow, errors) {
149
+ const stepProps = flow.stepProperties;
150
+ const stepMsgAttrs = flow.stepMessageAttributes;
151
+ if (!stepProps || !stepMsgAttrs) return;
152
+
153
+ const propKeys = new Set(Object.keys(stepProps));
154
+ for (const key of Object.keys(stepMsgAttrs)) {
155
+ if (propKeys.has(key)) {
156
+ errors.push(
157
+ `Flow '${flowTag}' key '${key}' is declared in both 'stepProperties' and 'stepMessageAttributes'. Keys must be unique across both groups.`
158
+ );
159
+ }
160
+ }
161
+ }
162
+
51
163
  export function validateFlowsForGeneration(flows) {
52
164
  const errors = [];
53
165
  const seenCustomFlowTags = new Set();
@@ -86,7 +198,8 @@ export function validateFlowsForGeneration(flows) {
86
198
  const seenStepNames = new Set();
87
199
  let anyStepHasPlugInHooks = false;
88
200
 
89
- for (const step of flowSteps) {
201
+ for (let i = 0; i < flowSteps.length; i++) {
202
+ const step = flowSteps[i];
90
203
  const stepName = step?.stepName;
91
204
  if (!stepName) {
92
205
  errors.push(`Flow '${flowTag}' has a step without stepName.`);
@@ -110,6 +223,30 @@ export function validateFlowsForGeneration(flows) {
110
223
 
111
224
  validateNamedRefs(flowTag, stepName, step.properties, flow.stepProperties, 'properties', errors);
112
225
  validateNamedRefs(flowTag, stepName, step.messageAttributes, flow.stepMessageAttributes, 'messageAttributes', errors);
226
+ if (step.returnValues) {
227
+ if (i === 0) {
228
+ if (flow.outputTopic === true) {
229
+ validateNamedRefs(flowTag, stepName, step.returnValues.properties, flow.stepProperties, 'returnValues.properties', errors);
230
+ validateNamedRefs(flowTag, stepName, step.returnValues.messageAttributes, flow.stepMessageAttributes, 'returnValues.messageAttributes', errors);
231
+ }
232
+ } else {
233
+ errors.push(`Flow '${flowTag}' step '${stepName}' must not have 'returnValues'. It is only allowed on the first step (index 0).`);
234
+ }
235
+ }
236
+ }
237
+
238
+ validateStepPropertyTypes(flowTag, flow.stepProperties, 'stepProperties', errors);
239
+ validateStepPropertyTypes(flowTag, flow.stepMessageAttributes, 'stepMessageAttributes', errors);
240
+ validateDuplicateStepPropertyKeys(flowTag, flow, errors);
241
+ validateUnusedStepProperties(flowTag, flow, errors);
242
+
243
+ if (flow.outputTopic === true) {
244
+ const firstStep = flowSteps[0];
245
+ const hasReturnProps = firstStep?.returnValues?.properties && Array.isArray(firstStep.returnValues.properties) && firstStep.returnValues.properties.length > 0;
246
+ const hasReturnMsgAttrs = firstStep?.returnValues?.messageAttributes && Array.isArray(firstStep.returnValues.messageAttributes) && firstStep.returnValues.messageAttributes.length > 0;
247
+ if (!hasReturnProps && !hasReturnMsgAttrs) {
248
+ errors.push(`Flow '${flowTag}' has 'outputTopic: true' but its first step '${firstStep?.stepName || 'Start'}' does not define any non-empty 'returnValues' (properties or messageAttributes).`);
249
+ }
113
250
  }
114
251
 
115
252
  // ponytail: relaxed anyStepHasPlugInHooks requirement
@@ -1,20 +1,18 @@
1
- const TYPE_MAP = {
2
- string: 'string', number: 'number', integer: 'number',
3
- currency: 'string', currencyValue: 'number', numeric: 'number',
4
- float: 'number', special: 'string', object: 'object',
5
- boolean: 'boolean', array: 'array', arrayMixed: 'array',
6
- arrayObject: 'array', arrayNumeric: 'array', arrayString: 'array',
7
- timestamp: 'number'
8
- };
1
+ import { consts } from '@izara_project/izara-core-library-service-schemas';
2
+
3
+ export const TYPE_MAP = consts.FIELDNAME_TYPE_MAPPING;
9
4
 
10
5
  export function normalizePropertyType(p, lookupSource) {
11
- let prop = typeof p === 'string'
12
- ? ((lookupSource && lookupSource[p]) ? lookupSource[p] : { propertyName: p, type: 'string' })
13
- : p;
14
-
15
- if (prop && prop.type) {
16
- prop = { ...prop, type: TYPE_MAP[prop.type] || prop.type };
17
- }
18
-
19
- return prop;
6
+ let prop =
7
+ typeof p === 'string'
8
+ ? lookupSource && lookupSource[p]
9
+ ? { propertyName: p, ...lookupSource[p] }
10
+ : { propertyName: p, type: 'string' }
11
+ : p;
12
+
13
+ if (prop && prop.type) {
14
+ prop = { ...prop, type: TYPE_MAP[prop.type] || prop.type };
15
+ }
16
+
17
+ return prop;
20
18
  }
@@ -31,7 +31,6 @@ import { generateProcessLogicalYaml } from './codeGenerators/app/sls_yaml/Proces
31
31
  import { generateSharedResourceYaml } from './codeGenerators/app/sls_yaml/SharedResourceYamlGenerator.js';
32
32
  import { generateFindDataYaml } from './codeGenerators/app/sls_yaml/FindDataYamlGenerator.js';
33
33
  import { policyRegistry } from './codeGenerators/app/sls_yaml/_policy/PolicyRegistry.js';
34
- import { emitManagedPolicies } from './codeGenerators/app/sls_yaml/_policy/PolicyEmitter.js';
35
34
 
36
35
  function createGeneratorOptions(rootPath, options = {}) {
37
36
  return {
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import ejs from 'ejs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { attributeTreeLib } from '@izara_project/izara-attribute-tree';
6
+ import { createBasicRelationshipsProperties } from './relationshipSchemaHelpers.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
@@ -12,47 +13,6 @@ function upperCase(str) {
12
13
  return str.charAt(0).toUpperCase() + str.slice(1);
13
14
  }
14
15
 
15
- function createBasicRelationshipsProperties(from, to, storageResources, baseDirection) {
16
- const otherDirection = baseDirection === 'to' ? 'from' : 'to';
17
- return {
18
- fieldNames: {
19
- originTimeStamp: {
20
- type: 'number',
21
- requiredOnCreate: true,
22
- canUpdate: false,
23
- validation: {
24
- minimum: 1111111111111,
25
- maximum: 9999999999999
26
- }
27
- }
28
- },
29
- storageResources: storageResources,
30
- links: [
31
- {
32
- storageResourceTags: Object.keys(storageResources),
33
- from: {
34
- objType: {
35
- objectType: from.objectType,
36
- serviceTag: from.serviceTag
37
- },
38
- linkType: from.linkType,
39
- direction: baseDirection,
40
- requiredOnCreate: from.requiredOnCreate
41
- },
42
- to: {
43
- objType: {
44
- objectType: to.objectType,
45
- serviceTag: to.serviceTag
46
- },
47
- linkType: to.linkType,
48
- direction: otherDirection,
49
- requiredOnCreate: to.requiredOnCreate
50
- }
51
- }
52
- ]
53
- };
54
- }
55
-
56
16
  export async function generateAttributeTreeRelationshipSchemas(hydratedObjectData, options) {
57
17
  console.log(' [AttributeTreeRelSchemaGenerator] Generating dynamic AttributeTree RelationshipSchemas...');
58
18
  const schemasOutputDir = path.join(
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import ejs from 'ejs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import propertyNode from '@izara_project/izara-property-nodes';
6
+ import { createBasicRelationshipsProperties } from './relationshipSchemaHelpers.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
@@ -12,47 +13,6 @@ function upperCase(str) {
12
13
  return str.charAt(0).toUpperCase() + str.slice(1);
13
14
  }
14
15
 
15
- function createBasicRelationshipsProperties(from, to, storageResources, baseDirection) {
16
- const otherDirection = baseDirection === 'to' ? 'from' : 'to';
17
- return {
18
- fieldNames: {
19
- originTimeStamp: {
20
- type: 'number',
21
- requiredOnCreate: true,
22
- canUpdate: false,
23
- validation: {
24
- minimum: 1111111111111,
25
- maximum: 9999999999999
26
- }
27
- }
28
- },
29
- storageResources: storageResources,
30
- links: [
31
- {
32
- storageResourceTags: Object.keys(storageResources),
33
- from: {
34
- objType: {
35
- objectType: from.objectType,
36
- serviceTag: from.serviceTag
37
- },
38
- linkType: from.linkType,
39
- direction: baseDirection,
40
- requiredOnCreate: from.requiredOnCreate
41
- },
42
- to: {
43
- objType: {
44
- objectType: to.objectType,
45
- serviceTag: to.serviceTag
46
- },
47
- linkType: to.linkType,
48
- direction: otherDirection,
49
- requiredOnCreate: to.requiredOnCreate
50
- }
51
- }
52
- ]
53
- };
54
- }
55
-
56
16
  export async function generatePropertyValueRelationshipSchemas(hydratedObjectData, options) {
57
17
  console.log(' [PropertyValueRelSchemaGenerator] Generating dynamic PropertyValue RelationshipSchemas...');
58
18
  const schemasOutputDir = path.join(
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { createBasicRelationshipsProperties } from '../relationshipSchemaHelpers.js';
3
+
4
+ describe('createBasicRelationshipsProperties', () => {
5
+ it('preserves the relationship schema shape and opposite direction', () => {
6
+ expect(createBasicRelationshipsProperties(
7
+ { serviceTag: 'Svc', objectType: 'Source', linkType: 'one', requiredOnCreate: false },
8
+ { serviceTag: 'Svc', objectType: 'Target', linkType: 'many', requiredOnCreate: true },
9
+ { graph: { storageType: 'graph' } },
10
+ 'from'
11
+ )).toEqual({
12
+ fieldNames: {
13
+ originTimeStamp: {
14
+ type: 'number',
15
+ requiredOnCreate: true,
16
+ canUpdate: false,
17
+ validation: { minimum: 1111111111111, maximum: 9999999999999 }
18
+ }
19
+ },
20
+ storageResources: { graph: { storageType: 'graph' } },
21
+ links: [{
22
+ storageResourceTags: ['graph'],
23
+ from: {
24
+ objType: { serviceTag: 'Svc', objectType: 'Source' },
25
+ linkType: 'one',
26
+ direction: 'from',
27
+ requiredOnCreate: false
28
+ },
29
+ to: {
30
+ objType: { serviceTag: 'Svc', objectType: 'Target' },
31
+ linkType: 'many',
32
+ direction: 'to',
33
+ requiredOnCreate: true
34
+ }
35
+ }]
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,40 @@
1
+ export function createBasicRelationshipsProperties(from, to, storageResources, baseDirection) {
2
+ const otherDirection = baseDirection === 'to' ? 'from' : 'to';
3
+ return {
4
+ fieldNames: {
5
+ originTimeStamp: {
6
+ type: 'number',
7
+ requiredOnCreate: true,
8
+ canUpdate: false,
9
+ validation: {
10
+ minimum: 1111111111111,
11
+ maximum: 9999999999999
12
+ }
13
+ }
14
+ },
15
+ storageResources,
16
+ links: [
17
+ {
18
+ storageResourceTags: Object.keys(storageResources),
19
+ from: {
20
+ objType: {
21
+ objectType: from.objectType,
22
+ serviceTag: from.serviceTag
23
+ },
24
+ linkType: from.linkType,
25
+ direction: baseDirection,
26
+ requiredOnCreate: from.requiredOnCreate
27
+ },
28
+ to: {
29
+ objType: {
30
+ objectType: to.objectType,
31
+ serviceTag: to.serviceTag
32
+ },
33
+ linkType: to.linkType,
34
+ direction: otherDirection,
35
+ requiredOnCreate: to.requiredOnCreate
36
+ }
37
+ }
38
+ ]
39
+ };
40
+ }
@@ -1,46 +0,0 @@
1
- // src/codeGenerators/app/sls_yaml/_policy/PolicyEmitter.js
2
- //
3
- // Reads the PolicyRegistry at the end of the generation run and emits one
4
- // per-role .yml file containing a single AWS::IAM::ManagedPolicy. Each
5
- // managed policy attaches to its base role via `Roles: [ !Ref <RoleName> ]`.
6
- //
7
- // IMPORTANT: This emitter runs AFTER SharedResourceYamlGenerator so the
8
- // managed policies can reference the roles that generator just emitted.
9
- // See generateCode.js for the ordering.
10
-
11
- import fs from 'fs/promises';
12
- import path from 'path';
13
- import ejs from 'ejs';
14
- import { fileURLToPath } from 'url';
15
-
16
- const __filename = fileURLToPath(import.meta.url);
17
- const __dirname = path.dirname(__filename);
18
-
19
- /**
20
- * @param {import('./PolicyRegistry.js').PolicyRegistry} registry
21
- * @param {{ outputPath: string }} options
22
- */
23
- export async function emitManagedPolicies(registry, options) {
24
- const outDir = path.join(
25
- options.outputPath,
26
- 'app',
27
- 'sls_yaml',
28
- 'generatedCode',
29
- 'source',
30
- 'iam-managed-policies'
31
- );
32
- await fs.mkdir(outDir, { recursive: true });
33
-
34
- const grouped = registry.toGroupedByRole();
35
- const templatePath = path.join(__dirname, 'templates', 'ManagedPolicy_Yaml.ejs');
36
- const tpl = await fs.readFile(templatePath, 'utf8');
37
-
38
- for (const [roleName, { statements }] of Object.entries(grouped)) {
39
- const policyName = roleName.replace(/role$/i, '');
40
- const content = ejs.render(tpl, { roleName, policyName, statements });
41
- const outFile = path.join(outDir, `${policyName}Policy.yml`);
42
- await fs.writeFile(outFile, content, 'utf8');
43
- }
44
-
45
- console.log(` [PolicyEmitter] Wrote ${Object.keys(grouped).length} managed-policy file(s) to ${outDir}`);
46
- }
@@ -1,55 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
- import fs from 'fs/promises';
3
- import path from 'path';
4
- import os from 'os';
5
- import { PolicyRegistry } from '../PolicyRegistry.js';
6
- import { emitManagedPolicies } from '../PolicyEmitter.js';
7
-
8
- describe('emitManagedPolicies', () => {
9
- let tmpDir, reg;
10
- beforeEach(async () => {
11
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iz-policy-emitter-'));
12
- reg = new PolicyRegistry();
13
- });
14
- afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); });
15
-
16
- it('emits one .yml file per registered role', async () => {
17
- reg.add('FindDataRole', 's3:GetObject', 'arn:aws:s3:::a/*');
18
- reg.add('RegisterRole', 'dynamodb:GetItem', 'arn:aws:dynamodb:*:*:table/x');
19
- await emitManagedPolicies(reg, { outputPath: tmpDir });
20
- const files = await fs.readdir(path.join(tmpDir, 'app', 'sls_yaml', 'generatedCode', 'source', 'iam-managed-policies'));
21
- expect(files.sort()).toEqual(['FindDataPolicy.yml', 'RegisterPolicy.yml']);
22
- });
23
-
24
- it('emits empty dir + no files when registry is empty', async () => {
25
- await emitManagedPolicies(reg, { outputPath: tmpDir });
26
- const outDir = path.join(tmpDir, 'app', 'sls_yaml', 'generatedCode', 'source', 'iam-managed-policies');
27
- const stat = await fs.stat(outDir);
28
- expect(stat.isDirectory()).toBe(true);
29
- const files = await fs.readdir(outDir);
30
- expect(files).toEqual([]);
31
- });
32
-
33
- it('writes YAML that references the role via !Ref', async () => {
34
- reg.add('FindDataRole', 's3:GetObject', 'arn:aws:s3:::a/*');
35
- await emitManagedPolicies(reg, { outputPath: tmpDir });
36
- const content = await fs.readFile(
37
- path.join(tmpDir, 'app', 'sls_yaml', 'generatedCode', 'source', 'iam-managed-policies', 'FindDataPolicy.yml'),
38
- 'utf8'
39
- );
40
- expect(content).toContain('Type: AWS::IAM::ManagedPolicy');
41
- expect(content).toContain('Roles:');
42
- expect(content).toContain('!Ref FindDataRole');
43
- expect(content).toContain('s3:GetObject');
44
- });
45
-
46
- it('does NOT crash on roles with statements containing Serverless CFN variables', async () => {
47
- reg.add('FindDataRole', 's3:GetObject', 'arn:aws:s3:::${self:custom.iz_serviceSchemaBucketName}/*');
48
- await emitManagedPolicies(reg, { outputPath: tmpDir });
49
- const content = await fs.readFile(
50
- path.join(tmpDir, 'app', 'sls_yaml', 'generatedCode', 'source', 'iam-managed-policies', 'FindDataPolicy.yml'),
51
- 'utf8'
52
- );
53
- expect(content).toContain('${self:custom.iz_serviceSchemaBucketName}');
54
- });
55
- });
@@ -1,22 +0,0 @@
1
- Resources:
2
- <%- policyName %>Policy:
3
- Type: AWS::IAM::ManagedPolicy
4
- Properties:
5
- ManagedPolicyName: ${self:custom.iz_resourcePrefix}<%- policyName %>Policy
6
- Description: "Managed policy for <%- roleName %> (auto-generated by PolicyEmitter)"
7
- PolicyDocument:
8
- Version: "2012-10-17"
9
- Statement:
10
- <% statements.forEach((stmt) => { -%>
11
- - Effect: Allow
12
- Action:
13
- <% stmt.action.forEach((a) => { -%>
14
- - <%- a %>
15
- <% }) -%>
16
- Resource:
17
- <% stmt.resource.forEach((r) => { -%>
18
- - "<%- r %>"
19
- <% }) -%>
20
- <% }) -%>
21
- Roles:
22
- - !Ref <%- roleName %>
package/test_schema.js DELETED
@@ -1,2 +0,0 @@
1
- import { basicValidatorSchema } from '@izara_project/izara-shared-service-schemas';
2
- console.log(JSON.stringify(basicValidatorSchema.basicFlowSchema?.properties?.flowSteps, null, 2));